backend-skeleton 1.2.0 → 1.4.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 +100 -1
- package/bin/bskel.mjs +687 -6
- package/contracts/csv.mjs +100 -0
- package/handles/providers/java-spring/rules.mjs +143 -0
- package/handles/providers/java-spring/templates/EnforceRules.java.tmpl +44 -0
- package/handles/providers/java-spring/templates/RuleCheck.java.tmpl +215 -0
- package/handles/providers/java-spring/templates/RuleEnforcementAspect.java.tmpl +132 -0
- package/handles/providers/java-spring/templates/RuleSetLoader.java.tmpl +138 -0
- package/handles/providers/python-fastapi/rules.mjs +133 -0
- package/handles/providers/python-fastapi/templates/enforce_rules.py.tmpl +128 -0
- package/handles/providers/python-fastapi/templates/rule_check.py.tmpl +178 -0
- package/handles/providers/python-fastapi/templates/rule_set.py.tmpl +59 -0
- package/handles/providers/typescript-express/rules.mjs +129 -0
- package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
- package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
- package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
- package/lib/cli.mjs +99 -1
- package/lib/doctor.mjs +11 -10
- package/lib/gate-definitions.mjs +27 -1
- package/lib/workflow.mjs +15 -0
- package/new/index.mjs +20 -0
- package/package.json +6 -2
- package/patterns/schema.sql +18 -0
- package/patterns/store.mjs +122 -0
- package/rules/compile.mjs +433 -0
- package/rules/derived.mjs +87 -0
- package/rules/diagnostics.mjs +147 -0
- package/rules/store.mjs +141 -0
- package/rules/vocabulary.mjs +172 -0
- package/scanners/adapters/_express-shared.mjs +7 -9
- package/scanners/adapters/java-spring.mjs +7 -9
- package/scanners/adapters/python-fastapi.mjs +7 -9
- package/scanners/db/erd.mjs +0 -0
- package/scanners/index.mjs +70 -17
- package/scanners/render.mjs +12 -3
- package/scanners/text-util.mjs +22 -0
- package/schemas/feature-rules.schema.json +139 -0
- package/schemas/pattern-record.schema.json +19 -0
- package/schemas/scan-report.schema.json +6 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// D-business-rules (R9): the emit-side half of compiled business rules for typescript-express.
|
|
2
|
+
// Mirrors handles/providers/python-fastapi/rules.mjs's own shape -- rules, observe, and handles
|
|
3
|
+
// are orthogonal capabilities that happen to share the same repo-wide "generated infra" pattern.
|
|
4
|
+
//
|
|
5
|
+
// This emitter is deliberately THIN. It renders three fixed infra modules and copies an
|
|
6
|
+
// already-compiled artifact into `rulesSchemas/` -- it makes no decision about what a rule means.
|
|
7
|
+
// Every such decision was made and verified in JS by rules/compile.mjs at `bskel rules check` time
|
|
8
|
+
// (R3), which is the invariant that lets three languages agree.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { emitUnits, unifiedDiff } from '../../_engine.mjs';
|
|
13
|
+
import { renderExprInfix, groupDerivedByResource } from '../../../rules/derived.mjs';
|
|
14
|
+
import { pascalCase } from '../../../rules/vocabulary.mjs';
|
|
15
|
+
|
|
16
|
+
const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
|
|
18
|
+
|
|
19
|
+
// Repo-wide, shared across every feature that ever runs `bskel rules emit` -- ruleSet.ts
|
|
20
|
+
// discovers every `rulesSchemas/*.rules.json` file at module-load time rather than being
|
|
21
|
+
// regenerated per feature, so these files are true infra (create-once-per-repo, all-or-nothing
|
|
22
|
+
// conflict unit), the same treatment observe.mjs's own INFRA_FILES get. None need any {{VAR}}
|
|
23
|
+
// substitution -- same reasoning observe's own INFRA_FILES give.
|
|
24
|
+
const INFRA_FILES = [
|
|
25
|
+
{ template: 'ruleCheck.ts.tmpl', target: 'ruleCheck.ts' },
|
|
26
|
+
{ template: 'ruleSet.ts.tmpl', target: 'ruleSet.ts' },
|
|
27
|
+
{ template: 'enforceRules.ts.tmpl', target: 'enforceRules.ts' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function render(templatePath, vars) {
|
|
31
|
+
let content = fs.readFileSync(templatePath, 'utf8');
|
|
32
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
33
|
+
content = content.replaceAll(`{{${key}}}`, String(value));
|
|
34
|
+
}
|
|
35
|
+
return content;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeUnit(target, content) {
|
|
39
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
40
|
+
fs.writeFileSync(target, content);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function camelCase(name) {
|
|
44
|
+
const pascal = pascalCase(name);
|
|
45
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// R5/Phase 3: one module per resource, one exported function per derived field -- same "complete,
|
|
49
|
+
// never a stub" posture java-spring's/python-fastapi's own renderers hold. All params are typed
|
|
50
|
+
// `number`: this vocabulary's operator set (add/sub/mul/div) is numeric-only by construction.
|
|
51
|
+
function renderDerivedModule(resource, rules, featureId) {
|
|
52
|
+
const functions = rules.map((rule) => {
|
|
53
|
+
const params = rule.params.map((p) => `${p}: number`).join(', ');
|
|
54
|
+
const formula = renderExprInfix(rule.expr);
|
|
55
|
+
return `/** \`${rule.field} = ${formula}\` -- rule "${rule.id}". */\nexport function compute${pascalCase(rule.field)}(${params}): number {\n\treturn ${formula};\n}`;
|
|
56
|
+
});
|
|
57
|
+
return `// Generated by backend-skeleton (bskel rules emit) for feature ${featureId}.
|
|
58
|
+
//
|
|
59
|
+
// D-business-rules (R5): a complete, compiling pure function per derived field -- never a stub.
|
|
60
|
+
// Nothing calls these; wire the call site yourself wherever a computed field on ${resource} should
|
|
61
|
+
// actually be set (e.g. before persisting).
|
|
62
|
+
|
|
63
|
+
${functions.join('\n\n')}
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `plan` is the already-computed typescript-express resource plan (bin/bskel.mjs calls
|
|
69
|
+
* planTypeScriptExpress() before this) -- only plan.srcRoot is used, matching observe.mjs's own
|
|
70
|
+
* tolerance-of-unused-fields pattern. `artifact` is the already-compiled, already-schema-validated
|
|
71
|
+
* rules artifact (rules/store.mjs's loadRulesArtifact) -- this function never reads specs/ itself.
|
|
72
|
+
*/
|
|
73
|
+
export function emitRulesTypeScriptExpress({ repoRoot, featureId, artifact, plan, force = false, reason = '', dryRun = false, computeDiff = false }) {
|
|
74
|
+
const rulesDir = path.join(plan.srcRoot, 'rules');
|
|
75
|
+
|
|
76
|
+
const infraUnits = INFRA_FILES.map((f) => ({
|
|
77
|
+
id: f.template,
|
|
78
|
+
templatePath: path.join(TEMPLATES_DIR, f.template),
|
|
79
|
+
targetAbs: path.join(rulesDir, f.target),
|
|
80
|
+
rendered: render(path.join(TEMPLATES_DIR, f.template), {}),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
// R5/Phase 3: one <resource>Rules.ts per resource with a derived field, emitted the SAME
|
|
84
|
+
// unconditional way the *.rules.json spec resource below is. See java-spring's own
|
|
85
|
+
// groupDerivedByResource() comment for the named cross-feature-collision limitation.
|
|
86
|
+
const derivedGroups = groupDerivedByResource(artifact.derived ?? []);
|
|
87
|
+
const derivedUnits = derivedGroups.map(([resource, rules]) => ({
|
|
88
|
+
resource,
|
|
89
|
+
targetAbs: path.join(rulesDir, `${camelCase(resource)}Rules.ts`),
|
|
90
|
+
content: renderDerivedModule(resource, rules, featureId),
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
|
|
94
|
+
|
|
95
|
+
for (const unit of derivedUnits) {
|
|
96
|
+
const relPath = path.relative(repoRoot, unit.targetAbs);
|
|
97
|
+
const diskContent = fs.existsSync(unit.targetAbs) ? fs.readFileSync(unit.targetAbs, 'utf8') : null;
|
|
98
|
+
const action = diskContent === null ? 'create' : (diskContent === unit.content ? 'unchanged' : 'update');
|
|
99
|
+
if (!dryRun) writeUnit(unit.targetAbs, unit.content);
|
|
100
|
+
result.written.push(relPath);
|
|
101
|
+
const actionEntry = { path: relPath, kind: 'derived', resourceType: unit.resource, action };
|
|
102
|
+
if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, unit.content);
|
|
103
|
+
result.actions.push(actionEntry);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The compiled artifact, copied verbatim -- byte-identical to the file `bskel rules check`
|
|
107
|
+
// already wrote and schema-validated, deliberately NOT re-serialized here, the same "exactly
|
|
108
|
+
// one representation of these rules" invariant every other provider's emitter holds.
|
|
109
|
+
const content = `${JSON.stringify(artifact, null, '\t')}\n`;
|
|
110
|
+
const target = path.join(rulesDir, 'rulesSchemas', `${featureId}.rules.json`);
|
|
111
|
+
const relPath = path.relative(repoRoot, target);
|
|
112
|
+
const diskContent = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
113
|
+
const action = diskContent === null ? 'create' : (diskContent === content ? 'unchanged' : 'update');
|
|
114
|
+
if (!dryRun) writeUnit(target, content);
|
|
115
|
+
result.written.push(relPath);
|
|
116
|
+
const actionEntry = { path: relPath, kind: 'spec', action };
|
|
117
|
+
if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, content);
|
|
118
|
+
result.actions.push(actionEntry);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
...result,
|
|
122
|
+
postEmitNotes: [
|
|
123
|
+
`field/cross rules are LOADED but not yet ACTIVE: insert enforceRules("<operationId>") into the real route's own middleware array to have them checked automatically on every request.`,
|
|
124
|
+
`defaults to OBSERVE (logs violations to stderr, never rejects a request) -- set the BSKEL_RULES_MODE=enforce environment variable when you're ready for a real violation to reject with HTTP 400. No re-run of \`bskel rules emit\` needed to switch.`,
|
|
125
|
+
`transition rules are NOT checked by enforceRules() -- they need the resource's CURRENT state, which no middleware can supply generically. Call ruleCheck.checkTransitions(rules, req.body, {"/status": current.status}) directly wherever your route handler has that state. A transition whose current state is not supplied reports "unchecked", never a silent pass.`,
|
|
126
|
+
...(derivedUnits.length > 0 ? [`derived field(s) compiled to ${derivedUnits.map((u) => `${camelCase(u.resource)}Rules.ts`).join(', ')}: complete, compiling pure functions, but NOTHING calls them -- wire the call site yourself. If another feature also declares a derived field on the same resource, whichever feature's \`rules emit\` runs LAST wins for that file -- not merged.`] : []),
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Generated by backend-skeleton (bskel rules emit). Do not hand-edit -- change the source rule
|
|
2
|
+
// and regenerate.
|
|
3
|
+
//
|
|
4
|
+
// D-business-rules (R8/R9): the opt-in AUTOMATIC half of business-rule checking -- exists only for
|
|
5
|
+
// a route a human has chosen to insert enforceRules('...') into. Mirrors observeContract.ts's own
|
|
6
|
+
// choice: Express middleware IS this ecosystem's own interception mechanism, so there is no
|
|
7
|
+
// separate "declare a marker" vs. "implement the interceptor" split worth preserving here either.
|
|
8
|
+
//
|
|
9
|
+
// SIMPLER than observeContract.ts's response-capture dance: this middleware only ever checks the
|
|
10
|
+
// REQUEST (field/cross rules have no response-shape concept), so there is no res.json patching,
|
|
11
|
+
// no res.on('finish', ...) -- a violation in enforce mode is rejected synchronously, before
|
|
12
|
+
// `next()` is ever called, the same way any ordinary Express validation middleware works.
|
|
13
|
+
//
|
|
14
|
+
// Checks FIELD and CROSS rules only. Does NOT check TRANSITION rules -- a transition guard needs
|
|
15
|
+
// the resource's CURRENT persisted state, which this middleware's own request-scoped context has
|
|
16
|
+
// no generic way to obtain. Call checkTransitions() directly, with the current state your own
|
|
17
|
+
// route handler already has, wherever a transition needs checking.
|
|
18
|
+
//
|
|
19
|
+
// The observe/enforce split is read from the BSKEL_RULES_MODE environment variable (default
|
|
20
|
+
// "observe") on every request, not baked in at emit time -- switching a deployed app from
|
|
21
|
+
// observation to enforcement is a config change, never a re-run of `bskel rules emit`. Same
|
|
22
|
+
// posture java-spring's `bskel.rules.mode` / python-fastapi's own BSKEL_RULES_MODE take.
|
|
23
|
+
//
|
|
24
|
+
// - observe (default): every violation is logged to stderr, the request always proceeds.
|
|
25
|
+
// - enforce: a violation responds 400 BEFORE the route handler runs. The response body may name a
|
|
26
|
+
// rule id, a JSON Pointer, and a rule's own declared bound; it NEVER contains an observed
|
|
27
|
+
// request value -- the same redaction invariant Violation carries.
|
|
28
|
+
//
|
|
29
|
+
// Fail-open on an INTERNAL error, fail-closed on a REAL violation: if this middleware itself
|
|
30
|
+
// cannot compute violations (a malformed body, a loader problem), that is logged and the request
|
|
31
|
+
// proceeds unaffected, in BOTH modes.
|
|
32
|
+
//
|
|
33
|
+
// Example:
|
|
34
|
+
// router.patch('/widgets/:id', [enforceRules('updateWidget')], updateWidget);
|
|
35
|
+
|
|
36
|
+
import type { RequestHandler, Request, Response, NextFunction } from 'express';
|
|
37
|
+
import * as ruleCheck from './ruleCheck';
|
|
38
|
+
import * as ruleSet from './ruleSet';
|
|
39
|
+
import type { RuleOperation, Violation } from './ruleCheck';
|
|
40
|
+
|
|
41
|
+
function mode(): string {
|
|
42
|
+
return process.env.BSKEL_RULES_MODE ?? 'observe';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function safelyCheck(rules: RuleOperation, body: unknown, operationId: string): Violation[] {
|
|
46
|
+
try {
|
|
47
|
+
if (body === undefined) return [];
|
|
48
|
+
return ruleCheck.check(rules, body);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn(`enforceRules: could not evaluate rules for operation "${operationId}" -- the request proceeds unaffected`, err);
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function logViolations(operationId: string, violations: Violation[], activeMode: string): void {
|
|
56
|
+
try {
|
|
57
|
+
for (const v of violations) {
|
|
58
|
+
console.warn(`bskel.rules.violations operation=${operationId} rule=${v.ruleId} pointer=${v.pointer} kind=${v.kind} mode=${activeMode} -- ${v.message}`);
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.warn(`enforceRules: could not log violations for operation "${operationId}"`, err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Built from rule id / pointer / bound-derived text only -- never an observed value, the same
|
|
66
|
+
* redaction invariant every Violation.message already holds. */
|
|
67
|
+
function summarize(operationId: string, violations: Violation[]): string {
|
|
68
|
+
const parts = violations.map((v) => `${v.pointer} (${v.ruleId}): ${v.message}`);
|
|
69
|
+
return `business rule violation(s) for ${operationId}: ${parts.join('; ')}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function enforceRules(operationId: string): RequestHandler {
|
|
73
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
74
|
+
const rules = ruleSet.forOperation(operationId);
|
|
75
|
+
if (rules.field.length === 0 && rules.cross.length === 0) {
|
|
76
|
+
// Nothing compiled for this operation -- most commonly a feature with no rules.yaml and
|
|
77
|
+
// a contract that projected nothing enforceable. Proceeds silently.
|
|
78
|
+
next();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const violations = safelyCheck(rules, req.body, operationId);
|
|
83
|
+
if (violations.length > 0) {
|
|
84
|
+
const activeMode = mode();
|
|
85
|
+
logViolations(operationId, violations, activeMode);
|
|
86
|
+
if (activeMode === 'enforce') {
|
|
87
|
+
res.status(400).json({ error: summarize(operationId, violations) });
|
|
88
|
+
return; // next() is never called -- the route handler must not run
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
next();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Generated by backend-skeleton (bskel rules emit). Do not hand-edit -- change the source rule
|
|
2
|
+
// and regenerate.
|
|
3
|
+
//
|
|
4
|
+
// D-business-rules (R3/R9): pure, dependency-free executor for one feature's compiled business
|
|
5
|
+
// rules. A DUMB executor by design -- every semantic decision (which assertions exist, whether one
|
|
6
|
+
// applies to a field's type, whether two fields are comparable, whether a transition's states are
|
|
7
|
+
// real) was already made in JS by rules/compile.mjs at `bskel rules check` time. Mirrors
|
|
8
|
+
// handles/providers/java-spring/templates/RuleCheck.java.tmpl (and its python-fastapi sibling
|
|
9
|
+
// rule_check.py.tmpl) field-for-field, so all three runtimes are guaranteed to agree.
|
|
10
|
+
//
|
|
11
|
+
// Redaction invariant, inherited from contractCheck.ts and equally binding here: `Violation.message`
|
|
12
|
+
// MUST NEVER interpolate an observed payload value -- only the JSON Pointer, the rule id, and the
|
|
13
|
+
// rule's own declared bound.
|
|
14
|
+
|
|
15
|
+
export interface Violation {
|
|
16
|
+
ruleId: string;
|
|
17
|
+
pointer: string;
|
|
18
|
+
kind: string;
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FieldRule {
|
|
23
|
+
id: string;
|
|
24
|
+
pointer: string;
|
|
25
|
+
assert: string;
|
|
26
|
+
value: unknown;
|
|
27
|
+
origin: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CrossRule {
|
|
31
|
+
id: string;
|
|
32
|
+
pointers: string[];
|
|
33
|
+
assert: string;
|
|
34
|
+
types: string[];
|
|
35
|
+
origin: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TransitionRule {
|
|
39
|
+
id: string;
|
|
40
|
+
pointer: string;
|
|
41
|
+
from: string[];
|
|
42
|
+
to: string[];
|
|
43
|
+
origin: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RuleOperation {
|
|
47
|
+
field: FieldRule[];
|
|
48
|
+
cross: CrossRule[];
|
|
49
|
+
transition: TransitionRule[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Compiled pointers are always a single top-level "/field" segment (rules/compile.mjs refuses
|
|
53
|
+
* anything deeper) -- this is a lookup, not a JSON Pointer implementation. */
|
|
54
|
+
function at(body: Record<string, unknown>, pointer: string): unknown {
|
|
55
|
+
const value = body[pointer.slice(1)];
|
|
56
|
+
return value === null || value === undefined ? undefined : value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CROSS_SYMBOLS: Record<string, string> = { lt: '<', lte: '<=', gt: '>', gte: '>=', eq: 'equal to', neq: 'different from' };
|
|
60
|
+
|
|
61
|
+
/** Runs every FIELD and CROSS rule in `rules` against a request body. TRANSITION rules are
|
|
62
|
+
* deliberately NOT run here -- see checkTransitions() for why they need an argument this function
|
|
63
|
+
* does not have. */
|
|
64
|
+
export function check(rules: RuleOperation, body: unknown): Violation[] {
|
|
65
|
+
const violations: Violation[] = [];
|
|
66
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) return violations;
|
|
67
|
+
const record = body as Record<string, unknown>;
|
|
68
|
+
for (const rule of rules.field) violations.push(...checkField(rule, record));
|
|
69
|
+
for (const rule of rules.cross) violations.push(...checkCross(rule, record));
|
|
70
|
+
return violations;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkField(rule: FieldRule, body: Record<string, unknown>): Violation[] {
|
|
74
|
+
const value = at(body, rule.pointer);
|
|
75
|
+
// An absent optional field is not a violation -- requiredness is the contract's own statement,
|
|
76
|
+
// enforced by contractCheck.ts, deliberately not duplicated in this vocabulary.
|
|
77
|
+
if (value === undefined) return [];
|
|
78
|
+
|
|
79
|
+
switch (rule.assert) {
|
|
80
|
+
case 'minLength': {
|
|
81
|
+
const bound = rule.value as number;
|
|
82
|
+
if (typeof value === 'string' && value.length < bound) {
|
|
83
|
+
return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'minLength', message: `shorter than the required minimum length of ${bound}` }];
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
case 'maxLength': {
|
|
88
|
+
const bound = rule.value as number;
|
|
89
|
+
if (typeof value === 'string' && value.length > bound) {
|
|
90
|
+
return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'maxLength', message: `longer than the permitted maximum length of ${bound}` }];
|
|
91
|
+
}
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
case 'minimum':
|
|
95
|
+
return compareNumber(rule, value, (a, b) => a < b, 'is below the permitted minimum of ');
|
|
96
|
+
case 'maximum':
|
|
97
|
+
return compareNumber(rule, value, (a, b) => a > b, 'is above the permitted maximum of ');
|
|
98
|
+
case 'exclusiveMinimum':
|
|
99
|
+
return compareNumber(rule, value, (a, b) => a <= b, 'must be strictly greater than ');
|
|
100
|
+
case 'exclusiveMaximum':
|
|
101
|
+
return compareNumber(rule, value, (a, b) => a >= b, 'must be strictly less than ');
|
|
102
|
+
case 'multipleOf': {
|
|
103
|
+
const bound = rule.value as number;
|
|
104
|
+
if (typeof value === 'number' && Number.isFinite(value) && bound) {
|
|
105
|
+
const remainder = value % bound;
|
|
106
|
+
if (Math.min(Math.abs(remainder), Math.abs(remainder - bound)) > 1e-9) {
|
|
107
|
+
return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'multipleOf', message: `is not an exact multiple of ${bound}` }];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
case 'enum': {
|
|
113
|
+
const allowed = (rule.value as unknown[]) ?? [];
|
|
114
|
+
if (!allowed.includes(value)) {
|
|
115
|
+
return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'enum', message: `is not one of the ${allowed.length} permitted values` }];
|
|
116
|
+
}
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
default:
|
|
120
|
+
// No default branch that passes silently: an assertion name this executor does not know
|
|
121
|
+
// can only mean the artifact was produced by a NEWER bskel than the code generated here.
|
|
122
|
+
return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'unsupported', message: `rule assertion "${rule.assert}" is not understood by this generated checker -- re-run \`bskel rules emit\` to regenerate it` }];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function compareNumber(rule: FieldRule, value: unknown, violates: (a: number, b: number) => boolean, messagePrefix: string): Violation[] {
|
|
127
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return [];
|
|
128
|
+
const bound = rule.value as number;
|
|
129
|
+
if (violates(value, bound)) {
|
|
130
|
+
return [{ ruleId: rule.id, pointer: rule.pointer, kind: rule.assert, message: `${messagePrefix}${bound}` }];
|
|
131
|
+
}
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function checkCross(rule: CrossRule, body: Record<string, unknown>): Violation[] {
|
|
136
|
+
const [first, second] = rule.pointers;
|
|
137
|
+
const a = at(body, first);
|
|
138
|
+
|
|
139
|
+
if (rule.assert === 'requiredIf') {
|
|
140
|
+
if (a !== undefined && at(body, second) === undefined) {
|
|
141
|
+
return [{ ruleId: rule.id, pointer: second, kind: 'requiredIf', message: `is required because ${first} is present` }];
|
|
142
|
+
}
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
if (rule.assert === 'mutuallyExclusive') {
|
|
146
|
+
const present = rule.pointers.filter((p) => at(body, p) !== undefined);
|
|
147
|
+
if (present.length > 1) {
|
|
148
|
+
return [{ ruleId: rule.id, pointer: first, kind: 'mutuallyExclusive', message: `at most one of ${rule.pointers.join(', ')} may be present, got ${present.length}` }];
|
|
149
|
+
}
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const b = at(body, second);
|
|
154
|
+
// Either side absent means there is nothing to compare -- same reasoning as RuleCheck.java.
|
|
155
|
+
if (a === undefined || b === undefined) return [];
|
|
156
|
+
|
|
157
|
+
let cmp: number;
|
|
158
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
159
|
+
cmp = a === b ? 0 : a > b ? 1 : -1;
|
|
160
|
+
} else if (typeof a === 'string' && typeof b === 'string') {
|
|
161
|
+
cmp = a === b ? 0 : a > b ? 1 : -1;
|
|
162
|
+
} else {
|
|
163
|
+
// rules/compile.mjs proved these two pointers were mutually comparable against the contract's
|
|
164
|
+
// declared types, so reaching here means the real payload disagrees with the contract.
|
|
165
|
+
return [{ ruleId: rule.id, pointer: first, kind: 'incomparable', message: `could not be compared with ${second} -- the payload types differ from the contract's declared types` }];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!(rule.assert in CROSS_SYMBOLS)) {
|
|
169
|
+
return [{ ruleId: rule.id, pointer: first, kind: 'unsupported', message: `rule assertion "${rule.assert}" is not understood by this generated checker -- re-run \`bskel rules emit\` to regenerate it` }];
|
|
170
|
+
}
|
|
171
|
+
const violated = ({ lt: cmp >= 0, lte: cmp > 0, gt: cmp <= 0, gte: cmp < 0, eq: cmp !== 0, neq: cmp === 0 } as Record<string, boolean>)[rule.assert];
|
|
172
|
+
if (violated) {
|
|
173
|
+
return [{ ruleId: rule.id, pointer: first, kind: rule.assert, message: `must be ${CROSS_SYMBOLS[rule.assert]} ${second}` }];
|
|
174
|
+
}
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Runs TRANSITION rules. Separate from check() because a transition guard is inherently a
|
|
180
|
+
* statement about a change, and a request body only carries the DESTINATION state -- the current
|
|
181
|
+
* state lives in the application's own datastore, which this function has no access to and must
|
|
182
|
+
* never acquire.
|
|
183
|
+
*
|
|
184
|
+
* `currentStates` maps a rule's pointer (e.g. "/status") to that resource's CURRENT persisted
|
|
185
|
+
* value, supplied by the caller. This is the one place a human must wire something -- the same
|
|
186
|
+
* honest boundary resolver.ts's own patchField draws.
|
|
187
|
+
*
|
|
188
|
+
* Fail-closed: a pointer with no entry in `currentStates` produces an "unchecked" violation, never
|
|
189
|
+
* a silent pass.
|
|
190
|
+
*/
|
|
191
|
+
export function checkTransitions(rules: RuleOperation, body: unknown, currentStates: Record<string, string> | null): Violation[] {
|
|
192
|
+
const violations: Violation[] = [];
|
|
193
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) return violations;
|
|
194
|
+
const record = body as Record<string, unknown>;
|
|
195
|
+
for (const rule of rules.transition) {
|
|
196
|
+
const target = at(record, rule.pointer);
|
|
197
|
+
if (typeof target !== 'string') continue; // not changing this field
|
|
198
|
+
if (currentStates === null || !(rule.pointer in currentStates)) {
|
|
199
|
+
violations.push({ ruleId: rule.id, pointer: rule.pointer, kind: 'unchecked', message: 'a transition guard is declared but the current state was not supplied, so it could not be evaluated -- pass it via checkTransitions(..., currentStates)' });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const fromState = currentStates[rule.pointer];
|
|
203
|
+
const toState = target;
|
|
204
|
+
if (fromState === toState) continue; // no transition is occurring
|
|
205
|
+
if (rule.from.includes(fromState) && rule.to.includes(toState)) continue; // explicitly allowed
|
|
206
|
+
violations.push({ ruleId: rule.id, pointer: rule.pointer, kind: 'transition', message: `this state change is not permitted -- allowed transitions are from {${rule.from.join(', ')}} to {${rule.to.join(', ')}}` });
|
|
207
|
+
}
|
|
208
|
+
return violations;
|
|
209
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Generated by backend-skeleton (bskel rules emit). Do not hand-edit -- change the source rule
|
|
2
|
+
// and regenerate.
|
|
3
|
+
//
|
|
4
|
+
// D-business-rules (R9): loads every `<feature-id>.rules.json` file under this package's own
|
|
5
|
+
// `rulesSchemas/` directory (one per feature `bskel rules emit` has been run against) at MODULE
|
|
6
|
+
// LOAD time and merges them into one flat map keyed by operationId. Mirrors observedSchema.ts's
|
|
7
|
+
// own shape exactly -- same `fs.readdirSync` discovery, same CommonJS `__dirname` anchor (see that
|
|
8
|
+
// module's own doc comment for the full reasoning).
|
|
9
|
+
//
|
|
10
|
+
// This module does NO interpretation. It deserializes an artifact whose every semantic decision
|
|
11
|
+
// was already made and verified in JS by rules/compile.mjs -- see ruleCheck.ts's own doc comment
|
|
12
|
+
// for that boundary.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import type { RuleOperation } from './ruleCheck';
|
|
17
|
+
|
|
18
|
+
const SCHEMAS_DIR = path.join(__dirname, 'rulesSchemas');
|
|
19
|
+
|
|
20
|
+
const EMPTY_OPERATION: RuleOperation = { field: [], cross: [], transition: [] };
|
|
21
|
+
|
|
22
|
+
function loadOne(filePath: string): Record<string, RuleOperation> {
|
|
23
|
+
let raw: any;
|
|
24
|
+
try {
|
|
25
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.warn(`ruleSet: could not read/parse ${filePath} -- its rules will not be enforced`, err);
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
const operations: Record<string, RuleOperation> = {};
|
|
31
|
+
for (const [operationId, entry] of Object.entries<any>(raw.operations ?? {})) {
|
|
32
|
+
operations[operationId] = {
|
|
33
|
+
field: entry.field ?? [],
|
|
34
|
+
cross: entry.cross ?? [],
|
|
35
|
+
transition: entry.transition ?? [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return operations;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadAll(): Map<string, RuleOperation> {
|
|
42
|
+
const merged = new Map<string, RuleOperation>();
|
|
43
|
+
if (!fs.existsSync(SCHEMAS_DIR)) return merged;
|
|
44
|
+
const files = fs.readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.rules.json')).sort();
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
for (const [operationId, ops] of Object.entries(loadOne(path.join(SCHEMAS_DIR, file)))) {
|
|
47
|
+
if (merged.has(operationId)) {
|
|
48
|
+
console.warn(`ruleSet: operationId "${operationId}" is declared by more than one *.rules.json on disk -- the last one loaded wins`);
|
|
49
|
+
}
|
|
50
|
+
merged.set(operationId, ops);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return merged;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const OPERATIONS = loadAll();
|
|
57
|
+
|
|
58
|
+
/** Never undefined -- an operation with no rules returns the shared empty operation object. */
|
|
59
|
+
export function forOperation(operationId: string): RuleOperation {
|
|
60
|
+
return OPERATIONS.get(operationId) ?? EMPTY_OPERATION;
|
|
61
|
+
}
|
package/lib/cli.mjs
CHANGED
|
@@ -241,6 +241,33 @@ export const COMMANDS = {
|
|
|
241
241
|
json: { type: 'boolean', default: false },
|
|
242
242
|
},
|
|
243
243
|
},
|
|
244
|
+
// D-contract-csv: deliberately fewer flags than `contract export` -- no `--allow-unprefixed`
|
|
245
|
+
// (there is no hard refusal here to override, only a warning that always fires), no
|
|
246
|
+
// `--status-codes` (not an OpenAPI-shaped concept). `--bom` is CSV-specific: opt-in UTF-8 BOM
|
|
247
|
+
// for Excel-on-Windows, see D-contract-csv in DECISIONS.md.
|
|
248
|
+
'contract export-csv': {
|
|
249
|
+
usage: 'bskel contract export-csv --feature <id> [--out <path>] [--bom] [--json]',
|
|
250
|
+
options: {
|
|
251
|
+
feature: { type: 'string', default: null, required: true },
|
|
252
|
+
out: { type: 'string', default: null },
|
|
253
|
+
bom: { type: 'boolean', default: false },
|
|
254
|
+
json: { type: 'boolean', default: false },
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
// D-db-erd: the other "recognizable artifact" export, read-only, repo-independent like
|
|
258
|
+
// `bskel new` (no --feature -- the database plane is not feature-scoped, see E6 in D-db-erd,
|
|
259
|
+
// DECISIONS.md). Deliberately no `--db` flag: `db` IS the verb, so it's implied -- internally
|
|
260
|
+
// reuses `resolveDbSchemaOrExit(root, {...flags, db:true})` unchanged, so env-var handling and
|
|
261
|
+
// error strings stay byte-identical to `scan --db`.
|
|
262
|
+
'db erd': {
|
|
263
|
+
usage: 'bskel db erd [--database-url-env <NAME>] [--schema public] [--out <path>] [--json]',
|
|
264
|
+
options: {
|
|
265
|
+
'database-url-env': { type: 'string', default: null },
|
|
266
|
+
schema: { type: 'string', default: 'public' },
|
|
267
|
+
out: { type: 'string', default: null },
|
|
268
|
+
json: { type: 'boolean', default: false },
|
|
269
|
+
},
|
|
270
|
+
},
|
|
244
271
|
// D-contract-history: read-only, so no --module/--openapi-file/etc -- it only ever reads what
|
|
245
272
|
// git already recorded for this feature's own contract file.
|
|
246
273
|
'contract history': {
|
|
@@ -312,6 +339,43 @@ export const COMMANDS = {
|
|
|
312
339
|
json: { type: 'boolean', default: false },
|
|
313
340
|
},
|
|
314
341
|
},
|
|
342
|
+
// D-business-rules: `check` compiles + verifies + establishes the `rules` gate; `list`/`explain`
|
|
343
|
+
// are read-only. `--init` writes a starter rules.yaml only when none exists (never overwrites).
|
|
344
|
+
'rules check': {
|
|
345
|
+
usage: 'bskel rules check --feature <id> [--init] [--json]',
|
|
346
|
+
options: {
|
|
347
|
+
feature: { type: 'string', default: null, required: true },
|
|
348
|
+
init: { type: 'boolean', default: false },
|
|
349
|
+
json: { type: 'boolean', default: false },
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
'rules list': {
|
|
353
|
+
usage: 'bskel rules list --feature <id> [--json]',
|
|
354
|
+
options: {
|
|
355
|
+
feature: { type: 'string', default: null, required: true },
|
|
356
|
+
json: { type: 'boolean', default: false },
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
'rules explain': {
|
|
360
|
+
usage: 'bskel rules explain --feature <id> --rule <id> [--json]',
|
|
361
|
+
options: {
|
|
362
|
+
feature: { type: 'string', default: null, required: true },
|
|
363
|
+
rule: { type: 'string', default: null, required: true },
|
|
364
|
+
json: { type: 'boolean', default: false },
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
'rules emit': {
|
|
368
|
+
usage: 'bskel rules emit --feature <id> [--module <name>] [--check] [--diff] [--force --reason "..."] [--json]',
|
|
369
|
+
options: {
|
|
370
|
+
feature: { type: 'string', default: null, required: true },
|
|
371
|
+
module: { type: 'string', default: null },
|
|
372
|
+
check: { type: 'boolean', default: false },
|
|
373
|
+
diff: { type: 'boolean', default: false },
|
|
374
|
+
force: { type: 'boolean', default: false },
|
|
375
|
+
reason: { type: 'string', default: '' },
|
|
376
|
+
json: { type: 'boolean', default: false },
|
|
377
|
+
},
|
|
378
|
+
},
|
|
315
379
|
'stack apply': {
|
|
316
380
|
usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]',
|
|
317
381
|
options: {
|
|
@@ -425,12 +489,18 @@ export const COMMANDS = {
|
|
|
425
489
|
// nobody actually asked for. The real defaults live in new/spring.mjs / new/fastapi.mjs, one
|
|
426
490
|
// place each.
|
|
427
491
|
new: {
|
|
428
|
-
usage: 'bskel new --stack spring|fastapi --slug <name> [--dir <path>] [--offline] [--json] [--name <text>] [--description <text>] [--project-version <v>] [--group-id <pkg>] [--artifact-id <id>] [--package-name <pkg>] [--java-version <n>] [--packaging jar|war] [--dependencies a,b,c] [--add-dependencies a,b,c] [--python-version <spec>] [--port N] [--license <spdx>] [--database postgres|sqlite|none]',
|
|
492
|
+
usage: 'bskel new --stack spring|fastapi --slug <name> [--dir <path>] [--offline] [--json] [--name <text>] [--description <text>] [--project-version <v>] [--group-id <pkg>] [--artifact-id <id>] [--package-name <pkg>] [--java-version <n>] [--packaging jar|war] [--dependencies a,b,c] [--add-dependencies a,b,c] [--python-version <spec>] [--port N] [--license <spdx>] [--database postgres|sqlite|none] [--record-pattern --pattern-database-url-env <NAME>]',
|
|
429
493
|
options: {
|
|
430
494
|
stack: { type: 'string', default: null, required: true },
|
|
431
495
|
slug: { type: 'string', default: null, required: true },
|
|
432
496
|
dir: { type: 'string', default: null },
|
|
433
497
|
offline: { type: 'boolean', default: false },
|
|
498
|
+
// D-pattern-accrual: both opt-in, both required together -- omitting either leaves
|
|
499
|
+
// `bskel new` byte-identical to today (no recording, no DB connection attempted). The
|
|
500
|
+
// actual write is best-effort (never fails the scaffold); only the "flag given but the
|
|
501
|
+
// other half missing / env var unset" usage mistake is checked before scaffold.
|
|
502
|
+
'record-pattern': { type: 'boolean', default: false },
|
|
503
|
+
'pattern-database-url-env': { type: 'string', default: null },
|
|
434
504
|
|
|
435
505
|
// Both stacks.
|
|
436
506
|
name: { type: 'string', default: null },
|
|
@@ -466,6 +536,34 @@ export const COMMANDS = {
|
|
|
466
536
|
'boot-version': { type: 'string', default: null, hidden: true },
|
|
467
537
|
},
|
|
468
538
|
},
|
|
539
|
+
// D-pattern-accrual: all three read-only, all requiring --pattern-database-url-env --
|
|
540
|
+
// there is no meaningful "run without a live connection" mode, same posture as `handles audit`
|
|
541
|
+
// (O7). Distinct flag name from every other command's --database-url-env: that flag always means
|
|
542
|
+
// the TARGET APPLICATION's database; this one is the user's own cross-project pattern store.
|
|
543
|
+
'pattern list': {
|
|
544
|
+
usage: 'bskel pattern list --pattern-database-url-env <NAME> [--stack spring|fastapi] [--json]',
|
|
545
|
+
options: {
|
|
546
|
+
'pattern-database-url-env': { type: 'string', default: null, required: true },
|
|
547
|
+
stack: { type: 'string', default: null },
|
|
548
|
+
json: { type: 'boolean', default: false },
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
'pattern show': {
|
|
552
|
+
usage: 'bskel pattern show <pattern_id> --pattern-database-url-env <NAME> [--json]',
|
|
553
|
+
options: {
|
|
554
|
+
'pattern-database-url-env': { type: 'string', default: null, required: true },
|
|
555
|
+
json: { type: 'boolean', default: false },
|
|
556
|
+
},
|
|
557
|
+
allowPositionals: true,
|
|
558
|
+
},
|
|
559
|
+
'pattern suggest': {
|
|
560
|
+
usage: 'bskel pattern suggest --stack spring|fastapi --pattern-database-url-env <NAME> [--json]',
|
|
561
|
+
options: {
|
|
562
|
+
stack: { type: 'string', default: null, required: true },
|
|
563
|
+
'pattern-database-url-env': { type: 'string', default: null, required: true },
|
|
564
|
+
json: { type: 'boolean', default: false },
|
|
565
|
+
},
|
|
566
|
+
},
|
|
469
567
|
'handles patch approve': {
|
|
470
568
|
usage: 'bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]',
|
|
471
569
|
options: {
|
package/lib/doctor.mjs
CHANGED
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
// D1's lib/workflow.mjs separates `computeWorkflowState()` from its cmdStatus/cmdNext callers --
|
|
3
3
|
// this stays pure enough to unit test without spawning the CLI, and bin/bskel.mjs just renders
|
|
4
4
|
// whatever this returns.
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
6
5
|
import { detectBuildCommand } from './verify.mjs';
|
|
7
6
|
import { listCatalogChoices, loadCatalogEntry } from '../stack/apply.mjs';
|
|
8
7
|
import { detectAstHelperAvailable } from '../handles/providers/java-spring/ast-bridge.mjs';
|
|
8
|
+
// D-zero-config-scan: re-exported from scanners/text-util.mjs (a cycle-free leaf module), NOT
|
|
9
|
+
// defined here -- this file transitively imports scanners/registry.mjs (via ./verify.mjs), which
|
|
10
|
+
// dynamically import()s every scanner adapter; an adapter importing binaryAvailable() FROM here
|
|
11
|
+
// would close that cycle. See scanners/text-util.mjs's own comment on binaryAvailable() for the
|
|
12
|
+
// real hang this caused before the fix. Every existing caller of `binaryAvailable` from this file
|
|
13
|
+
// (bin/bskel.mjs) needs no change -- it's still available at this same import path.
|
|
14
|
+
import { binaryAvailable } from '../scanners/text-util.mjs';
|
|
15
|
+
|
|
16
|
+
export { binaryAvailable };
|
|
9
17
|
|
|
10
18
|
// D5: the three workflows that have tool requirements beyond "git + a supported Node runtime"
|
|
11
19
|
// (every workflow needs those two -- preflight/contract don't need anything ELSE, so they're
|
|
@@ -34,15 +42,8 @@ function nodeVersionOk(versionString) {
|
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
function binaryCheck(name, { required, remediation }) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
execFileSync(name, ['--version'], { stdio: 'pipe' });
|
|
41
|
-
} catch {
|
|
42
|
-
ok = false;
|
|
43
|
-
detail = 'not found on PATH';
|
|
44
|
-
}
|
|
45
|
-
return { name: `binary: ${name}`, required, ok, detail, remediation: ok ? null : remediation };
|
|
45
|
+
const ok = binaryAvailable(name);
|
|
46
|
+
return { name: `binary: ${name}`, required, ok, detail: ok ? '' : 'not found on PATH', remediation: ok ? null : remediation };
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
function nodeVersionCheck() {
|