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
package/rules/store.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// D-business-rules (R1/R2): the I/O boundary for business rules. Paths, reading the hand-authored
|
|
2
|
+
// YAML source, and reading/writing the compiled JSON artifact with schema validation on BOTH
|
|
3
|
+
// sides -- the exact shape lib/cross-feature-collisions.mjs's
|
|
4
|
+
// loadCrossFeatureResolution/saveCrossFeatureResolution and contracts/completeness.mjs's
|
|
5
|
+
// loadResolution/saveResolution already hold ("refusing to write an invalid ...").
|
|
6
|
+
//
|
|
7
|
+
// rules/compile.mjs stays pure and filesystem-free; everything that touches disk lives here.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { parse as parseYaml } from 'yaml';
|
|
11
|
+
import { specPath } from '../lib/paths.mjs';
|
|
12
|
+
import { readJsonIfExists, writeFileAtomic } from '../lib/fsutil.mjs';
|
|
13
|
+
import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
|
|
14
|
+
import { RULES_SOURCE_SCHEMA } from './compile.mjs';
|
|
15
|
+
|
|
16
|
+
// The hand-authored file. Sits directly in the feature's spec dir alongside dependencies.json --
|
|
17
|
+
// both are human-authored declarations about this feature's own fields, so they are siblings.
|
|
18
|
+
export function rulesSourcePath(root, featureId) {
|
|
19
|
+
return specPath(root, featureId, 'rules.yaml');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The compiled artifact. In its own `rules/` subdirectory, matching how `handles/migration.sql`
|
|
23
|
+
// already nests a generated artifact under the feature's spec dir.
|
|
24
|
+
export function rulesArtifactPath(root, featureId) {
|
|
25
|
+
return specPath(root, featureId, 'rules', `${featureId}.rules.json`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads and parses specs/<featureId>/rules.yaml. Returns null when the file does not exist at all
|
|
30
|
+
* -- a legal, common state meaning "this feature declares no hand-authored rules", which still
|
|
31
|
+
* compiles to a real artifact from contract-projected rules alone (R4).
|
|
32
|
+
*
|
|
33
|
+
* Throws on malformed YAML or a wrong/missing `schema` key. Deliberately NOT schema-validated
|
|
34
|
+
* beyond that: rules/compile.mjs resolves every rule against the contract itself and produces
|
|
35
|
+
* strictly better refusals (naming real known fields, real declared types, real enum states) than
|
|
36
|
+
* a structural schema could -- see schemas/feature-rules.schema.json's own description.
|
|
37
|
+
*/
|
|
38
|
+
export function loadRulesSource(root, featureId) {
|
|
39
|
+
const file = rulesSourcePath(root, featureId);
|
|
40
|
+
if (!fs.existsSync(file)) return null;
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = parseYaml(fs.readFileSync(file, 'utf8'));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(`${file}: not valid YAML -- ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
if (parsed === null || parsed === undefined) return { schema: RULES_SOURCE_SCHEMA, rules: [] };
|
|
48
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
49
|
+
throw new Error(`${file}: must be a YAML mapping with a "rules:" list, got ${Array.isArray(parsed) ? 'a list' : typeof parsed}`);
|
|
50
|
+
}
|
|
51
|
+
if (parsed.schema !== RULES_SOURCE_SCHEMA) {
|
|
52
|
+
throw new Error(`${file}: expected \`schema: ${RULES_SOURCE_SCHEMA}\` at the top of the file, got ${parsed.schema === undefined ? '(nothing)' : JSON.stringify(parsed.schema)}`);
|
|
53
|
+
}
|
|
54
|
+
if (parsed.rules !== undefined && !Array.isArray(parsed.rules)) {
|
|
55
|
+
throw new Error(`${file}: "rules" must be a list, got ${typeof parsed.rules}`);
|
|
56
|
+
}
|
|
57
|
+
return { schema: parsed.schema, rules: parsed.rules ?? [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadRulesArtifact(root, featureId) {
|
|
61
|
+
const file = rulesArtifactPath(root, featureId);
|
|
62
|
+
const parsed = readJsonIfExists(file);
|
|
63
|
+
if (parsed === null) return null;
|
|
64
|
+
const { ok, errors } = validateAgainstSchema('feature-rules.schema.json', parsed);
|
|
65
|
+
if (!ok) {
|
|
66
|
+
throw new Error(`${file}: does not match schemas/feature-rules.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function saveRulesArtifact(root, featureId, artifact) {
|
|
72
|
+
const { ok, errors } = validateAgainstSchema('feature-rules.schema.json', artifact);
|
|
73
|
+
if (!ok) {
|
|
74
|
+
throw new Error(`refusing to write an invalid rules artifact for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
75
|
+
}
|
|
76
|
+
const file = rulesArtifactPath(root, featureId);
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
// Tab-indented + trailing newline, matching every other generated JSON artifact in this repo
|
|
79
|
+
// (observe's own <feature>.observed-schema.json uses exactly this). Byte-stable across runs so
|
|
80
|
+
// a no-op recompile leaves the file untouched and the `rules` gate stays green.
|
|
81
|
+
writeFileAtomic(file, `${JSON.stringify(artifact, null, '\t')}\n`);
|
|
82
|
+
return artifact;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A starter rules.yaml, written only when the user explicitly asks for one and only when no file
|
|
86
|
+
// exists. Deliberately contains ZERO real rules -- every example is commented out. Writing a live
|
|
87
|
+
// rule here would be `bskel` inventing a constraint about the user's domain, exactly the line
|
|
88
|
+
// D-greenfield-parameters draws ("does the generated file encode a claim about the user's domain
|
|
89
|
+
// that the user did not state?").
|
|
90
|
+
export function starterRulesSource(featureId) {
|
|
91
|
+
return `# Business rules for ${featureId}.
|
|
92
|
+
#
|
|
93
|
+
# Compiled by \`bskel rules check --feature ${featureId}\` into
|
|
94
|
+
# specs/${featureId}/rules/${featureId}.rules.json, which is what the generated
|
|
95
|
+
# runtime checkers actually execute. Nothing here is enforced until you run that command.
|
|
96
|
+
#
|
|
97
|
+
# Constraints your OpenAPI document already states (minLength, maximum, enum, ...) are picked up
|
|
98
|
+
# AUTOMATICALLY from the contract -- you do not need to repeat them here. This file is only for
|
|
99
|
+
# the rules no schema keyword can express.
|
|
100
|
+
schema: ${RULES_SOURCE_SCHEMA}
|
|
101
|
+
rules: []
|
|
102
|
+
#
|
|
103
|
+
# Uncomment and adapt any of these -- then run \`bskel rules check --feature ${featureId}\`, which
|
|
104
|
+
# verifies every pointer, type, and enum state against your own contract before compiling.
|
|
105
|
+
#
|
|
106
|
+
# - id: end-after-start
|
|
107
|
+
# kind: cross
|
|
108
|
+
# operation: createBooking
|
|
109
|
+
# pointers: [/startDate, /endDate]
|
|
110
|
+
# assert: lt
|
|
111
|
+
# reason: "a booking cannot end before it starts"
|
|
112
|
+
#
|
|
113
|
+
# - id: publish-flow
|
|
114
|
+
# kind: transition
|
|
115
|
+
# operation: updateArticle
|
|
116
|
+
# pointer: /status
|
|
117
|
+
# from: [draft]
|
|
118
|
+
# to: [published, archived]
|
|
119
|
+
# reason: "an article may only be published or archived out of draft"
|
|
120
|
+
#
|
|
121
|
+
# - id: discount-cap
|
|
122
|
+
# kind: field
|
|
123
|
+
# operation: createOrder
|
|
124
|
+
# pointer: /discountPercent
|
|
125
|
+
# assert: maximum
|
|
126
|
+
# value: 50
|
|
127
|
+
# reason: "policy cap, not expressible in the public API schema"
|
|
128
|
+
#
|
|
129
|
+
# - id: order-total
|
|
130
|
+
# kind: derived
|
|
131
|
+
# resource: Order
|
|
132
|
+
# field: total
|
|
133
|
+
# expr:
|
|
134
|
+
# op: sub
|
|
135
|
+
# args:
|
|
136
|
+
# - op: mul
|
|
137
|
+
# args: [{ ref: price }, { ref: quantity }]
|
|
138
|
+
# - { ref: discount }
|
|
139
|
+
# reason: "total = price * quantity - discount; compiles to a real Order Rules pure function"
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// D-business-rules (R3): the closed vocabulary of business-rule assertions this project compiles
|
|
2
|
+
// into target-language code. Frozen, small, and every member mechanically executable in Java,
|
|
3
|
+
// Python, and TypeScript without interpretation -- that constraint is the whole design, not a
|
|
4
|
+
// convenience. See DECISIONS.md's D-business-rules, R3.
|
|
5
|
+
//
|
|
6
|
+
// This module is PURE DATA plus pure predicates over it. It imports nothing, so it can be read by
|
|
7
|
+
// the compiler, the CLI, and every provider emitter without any risk of the import cycle
|
|
8
|
+
// D-zero-config-scan hit (adapter -> lib/doctor.mjs -> lib/verify.mjs -> scanners/registry.mjs).
|
|
9
|
+
//
|
|
10
|
+
// DELIBERATE NON-OVERLAP with `handles/observe-schema-projection.mjs`. That module already
|
|
11
|
+
// projects `required`/`type`/`pattern` out of a contract's own requestBodySchema, and every
|
|
12
|
+
// provider's generated checker already enforces those three. This vocabulary covers exactly the
|
|
13
|
+
// constraints that projection DROPS -- so a single violation is never reported twice by two
|
|
14
|
+
// different checkers, and neither module has to know what the other kept. `required` as an
|
|
15
|
+
// unconditional assertion is therefore deliberately absent here: it is the contract's/OpenAPI
|
|
16
|
+
// document's own statement, not a business rule layered on top. Its genuinely-new sibling,
|
|
17
|
+
// `requiredIf` (conditional on another field), IS here, because no schema keyword expresses it.
|
|
18
|
+
|
|
19
|
+
// ---- field assertions: one scalar JSON Pointer, one bounded comparison ----------------------
|
|
20
|
+
//
|
|
21
|
+
// `valueType` is what the rule's own `value` must be (validated at compile time, so a generated
|
|
22
|
+
// runtime never has to defend against a malformed rule). `appliesTo` is the set of projected
|
|
23
|
+
// scalar types the assertion is meaningful against -- checked against the contract's own
|
|
24
|
+
// requestBodySchema at compile time (R6), which is how a rule that says "minLength on an integer"
|
|
25
|
+
// is refused with the real type named instead of silently never firing.
|
|
26
|
+
export const FIELD_ASSERTS = Object.freeze({
|
|
27
|
+
minLength: Object.freeze({ valueType: 'integer', appliesTo: Object.freeze(['string']), summary: 'string length >= value' }),
|
|
28
|
+
maxLength: Object.freeze({ valueType: 'integer', appliesTo: Object.freeze(['string']), summary: 'string length <= value' }),
|
|
29
|
+
minimum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number >= value' }),
|
|
30
|
+
maximum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number <= value' }),
|
|
31
|
+
exclusiveMinimum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number > value' }),
|
|
32
|
+
exclusiveMaximum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number < value' }),
|
|
33
|
+
multipleOf: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number is an exact multiple of value' }),
|
|
34
|
+
enum: Object.freeze({ valueType: 'array', appliesTo: Object.freeze(['string', 'number', 'integer', 'boolean']), summary: 'value is one of a fixed list' }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ---- cross-field assertions: two or more pointers, compared to each other -------------------
|
|
38
|
+
//
|
|
39
|
+
// `arity: 2` means exactly two pointers; `arity: 'n'` means two or more. `comparison: true` marks
|
|
40
|
+
// the members whose operands must be mutually comparable (both numeric, or both string) -- a
|
|
41
|
+
// `lt` between a string and an integer is refused at compile time rather than given some
|
|
42
|
+
// language-specific coercion behavior that would differ across the three runtimes.
|
|
43
|
+
export const CROSS_ASSERTS = Object.freeze({
|
|
44
|
+
lt: Object.freeze({ arity: 2, comparison: true, summary: 'first < second' }),
|
|
45
|
+
lte: Object.freeze({ arity: 2, comparison: true, summary: 'first <= second' }),
|
|
46
|
+
gt: Object.freeze({ arity: 2, comparison: true, summary: 'first > second' }),
|
|
47
|
+
gte: Object.freeze({ arity: 2, comparison: true, summary: 'first >= second' }),
|
|
48
|
+
eq: Object.freeze({ arity: 2, comparison: true, summary: 'first == second' }),
|
|
49
|
+
neq: Object.freeze({ arity: 2, comparison: true, summary: 'first != second' }),
|
|
50
|
+
requiredIf: Object.freeze({ arity: 2, comparison: false, summary: 'if the first is present, the second must be too' }),
|
|
51
|
+
mutuallyExclusive: Object.freeze({ arity: 'n', comparison: false, summary: 'at most one of these may be present' }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---- the three predicate rule kinds -----------------------------------------------------------
|
|
55
|
+
//
|
|
56
|
+
// `derived` is deliberately NOT here: it produces a value rather than answering true/false about
|
|
57
|
+
// one, so it cannot be executed by a predicate checker at all and takes a different compilation
|
|
58
|
+
// path entirely (R5). Keeping the predicate kinds in their own frozen list is what lets the
|
|
59
|
+
// compiler and every checker iterate them generically.
|
|
60
|
+
export const PREDICATE_KINDS = Object.freeze(['field', 'cross', 'transition']);
|
|
61
|
+
|
|
62
|
+
// Every kind an authored rule may declare -- PREDICATE_KINDS plus `derived`. Used only at the
|
|
63
|
+
// "is this kind even real" dispatch point in rules/compile.mjs; every other consumer (the
|
|
64
|
+
// checkers, `rules explain`'s search) still branches on PREDICATE_KINDS vs. `derived` separately,
|
|
65
|
+
// because the two are executed through genuinely different mechanisms (R5).
|
|
66
|
+
export const ALL_RULE_KINDS = Object.freeze([...PREDICATE_KINDS, 'derived']);
|
|
67
|
+
|
|
68
|
+
// R5/Phase 3: the closed arithmetic vocabulary a `derived` rule's expr tree may use. Deliberately
|
|
69
|
+
// tiny and binary-only (exactly 2 args per op) -- the explicit "no arbitrary arithmetic beyond the
|
|
70
|
+
// frozen operator set" scope boundary this item's own plan named. A third operand is expressed by
|
|
71
|
+
// nesting (`mul(mul(a,b),c)`), never by widening arity.
|
|
72
|
+
export const DERIVED_OPS = Object.freeze(['add', 'sub', 'mul', 'div']);
|
|
73
|
+
|
|
74
|
+
// PascalCase for Java/TypeScript method names (`computeTotal`), snake_case for Python
|
|
75
|
+
// (`compute_total`) -- pure string transforms, reused by all three provider emitters so a
|
|
76
|
+
// resource/field name is capitalized identically everywhere rather than three subtly different
|
|
77
|
+
// regexes drifting apart.
|
|
78
|
+
export function pascalCase(name) {
|
|
79
|
+
return String(name).replace(/(^\w|[-_]\w)/g, (m) => m.replace(/[-_]/, '').toUpperCase());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function snakeCase(name) {
|
|
83
|
+
return String(name)
|
|
84
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
85
|
+
.replace(/[-\s]+/g, '_')
|
|
86
|
+
.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Where a compiled rule came from. `contract` = projected from the feature's own
|
|
90
|
+
// requestBodySchema, i.e. a fact the user's OpenAPI document already asserts, transported (R4 --
|
|
91
|
+
// the same "copying is not synthesizing" posture D-openapi-passthrough established). `declared` =
|
|
92
|
+
// a human wrote it in rules.yaml. Kept distinct in the compiled artifact so an auditor can always
|
|
93
|
+
// tell which constraints the document licensed and which a human added on top.
|
|
94
|
+
export const RULE_ORIGINS = Object.freeze(['contract', 'declared']);
|
|
95
|
+
|
|
96
|
+
// The scalar types a rule can address, matching observe-schema-projection.mjs's own SCALAR_TYPES
|
|
97
|
+
// exactly -- a rule can only constrain something that module already projects as a scalar leaf.
|
|
98
|
+
export const RULE_SCALAR_TYPES = Object.freeze(['string', 'number', 'integer', 'boolean']);
|
|
99
|
+
|
|
100
|
+
export const FIELD_ASSERT_NAMES = Object.freeze(Object.keys(FIELD_ASSERTS).sort());
|
|
101
|
+
export const CROSS_ASSERT_NAMES = Object.freeze(Object.keys(CROSS_ASSERTS).sort());
|
|
102
|
+
|
|
103
|
+
export function getFieldAssert(name) {
|
|
104
|
+
return Object.hasOwn(FIELD_ASSERTS, name) ? FIELD_ASSERTS[name] : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getCrossAssert(name) {
|
|
108
|
+
return Object.hasOwn(CROSS_ASSERTS, name) ? CROSS_ASSERTS[name] : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The typo-defense points, same shape as lib/gate-definitions.mjs's requireGateDefinition() and
|
|
112
|
+
// contracts/completeness.mjs's requireWarningCode(): name the known values rather than letting a
|
|
113
|
+
// misspelled assertion silently compile to a rule that can never fire.
|
|
114
|
+
export function requireFieldAssert(name) {
|
|
115
|
+
const spec = getFieldAssert(name);
|
|
116
|
+
if (!spec) throw new Error(`unknown field assertion "${name}" -- known field assertions: ${FIELD_ASSERT_NAMES.join(', ')}`);
|
|
117
|
+
return spec;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function requireCrossAssert(name) {
|
|
121
|
+
const spec = getCrossAssert(name);
|
|
122
|
+
if (!spec) throw new Error(`unknown cross-field assertion "${name}" -- known cross-field assertions: ${CROSS_ASSERT_NAMES.join(', ')}`);
|
|
123
|
+
return spec;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Is `value` the right shape for this assertion's declared valueType? Deliberately strict:
|
|
127
|
+
// `integer` rejects 1.5 AND rejects a numeric string, because a rule whose operand needs coercing
|
|
128
|
+
// is a rule whose behavior would differ between Java, Python, and JS.
|
|
129
|
+
export function valueMatchesType(valueType, value) {
|
|
130
|
+
switch (valueType) {
|
|
131
|
+
case 'integer': return typeof value === 'number' && Number.isInteger(value);
|
|
132
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value);
|
|
133
|
+
case 'array': return Array.isArray(value) && value.length > 0;
|
|
134
|
+
case 'none': return value === undefined;
|
|
135
|
+
default: return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Two projected scalar types are comparable if they are both numeric or both string. Booleans are
|
|
140
|
+
// deliberately never comparable with `lt`/`gt` (ordering booleans is a language-specific accident,
|
|
141
|
+
// not a business rule); `eq`/`neq` between two booleans is still refused here for the same reason
|
|
142
|
+
// consistency matters more than convenience -- use a field `enum` assertion instead.
|
|
143
|
+
export function typesAreComparable(a, b) {
|
|
144
|
+
const numeric = new Set(['number', 'integer']);
|
|
145
|
+
if (numeric.has(a) && numeric.has(b)) return true;
|
|
146
|
+
return a === 'string' && b === 'string';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Renders one compiled rule as a plain-English sentence, for `bskel rules explain`. Pure and
|
|
150
|
+
// exported (rather than built inline in the CLI) so it has a direct unit test, and so the same
|
|
151
|
+
// wording can be reused by any future renderer -- the same call lib/workflow.mjs made when it
|
|
152
|
+
// exported isMutatingCommand() instead of inlining it.
|
|
153
|
+
export function explainRule({ kind, rule }) {
|
|
154
|
+
if (kind === 'field') {
|
|
155
|
+
const spec = getFieldAssert(rule.assert);
|
|
156
|
+
const what = spec ? spec.summary.replace('value', JSON.stringify(rule.value)) : `${rule.assert} ${JSON.stringify(rule.value)}`;
|
|
157
|
+
return `${rule.pointer} must satisfy: ${what}`;
|
|
158
|
+
}
|
|
159
|
+
if (kind === 'cross') {
|
|
160
|
+
const spec = getCrossAssert(rule.assert);
|
|
161
|
+
if (rule.assert === 'requiredIf') return `if ${rule.pointers[0]} is present, ${rule.pointers[1]} must be present too`;
|
|
162
|
+
if (rule.assert === 'mutuallyExclusive') return `at most one of ${rule.pointers.join(', ')} may be present`;
|
|
163
|
+
return `${rule.pointers[0]} must be ${spec ? spec.summary.replace('first ', '').replace(' second', '') : rule.assert} ${rule.pointers[1]}`;
|
|
164
|
+
}
|
|
165
|
+
if (kind === 'transition') {
|
|
166
|
+
return `${rule.pointer} may only change from {${rule.from.join(', ')}} to {${rule.to.join(', ')}}`;
|
|
167
|
+
}
|
|
168
|
+
if (kind === 'derived') {
|
|
169
|
+
return `${rule.resource}.${rule.field} is computed from (${rule.params.join(', ')}) -- see \`rules/derived.mjs\`'s renderExprInfix() for the exact formula, or the generated <Resource>Rules class itself`;
|
|
170
|
+
}
|
|
171
|
+
return `(no explanation available for kind "${kind}")`;
|
|
172
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
|
-
import { listRgFiles as sharedListRgFiles, byShallowestThenName } from '../text-util.mjs';
|
|
18
|
+
import { listRgFiles as sharedListRgFiles, byShallowestThenName, binaryAvailable } from '../text-util.mjs';
|
|
19
19
|
|
|
20
20
|
export const EXCLUDE_GLOBS = ['!**/node_modules/**', '!**/dist/**', '!**/build/**'];
|
|
21
21
|
export const VERBS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
@@ -220,14 +220,12 @@ export function expressDiagnostics(repoRoot) {
|
|
|
220
220
|
} else if (!pkgFiles.some((f) => declaresExpress(f))) {
|
|
221
221
|
messages.push({ level: 'info', code: 'express-not-a-dependency', message: `found ${pkgFiles.length} package.json file(s), but none declare an express dependency` });
|
|
222
222
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!rgOk) {
|
|
230
|
-
messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it and will throw, not degrade, if it is missing' });
|
|
223
|
+
// D-zero-config-scan: traced live -- this does NOT throw at scan time as the message used to
|
|
224
|
+
// claim. detect()'s own rg shell-outs (e.g. listCandidatePackageFiles()) are wrapped in a
|
|
225
|
+
// blanket try/catch that returns [] on failure, so a missing `rg` makes this adapter silently
|
|
226
|
+
// detect nothing (degrades to generic-grep) rather than crashing. Corrected below.
|
|
227
|
+
if (!binaryAvailable('rg')) {
|
|
228
|
+
messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it for file discovery and silently detects nothing without it (degrades to the generic-grep fallback), it does not throw' });
|
|
231
229
|
}
|
|
232
230
|
// D-openapi-extraction-hint: like FastAPI, both Express adapters declare `api.operations:
|
|
233
231
|
// false` -- --openapi-file is load-bearing for `contract emit` to adopt any operation, not just
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { lineNumberAt, listRgFiles, byShallowestThenName } from '../text-util.mjs';
|
|
8
|
+
import { lineNumberAt, listRgFiles, byShallowestThenName, binaryAvailable } from '../text-util.mjs';
|
|
9
9
|
import { maskNonCode, findClassOrRecordDeclaration, findClassLevelMappingArgs, findMappingAnnotations } from './_java-spring-analyzer.mjs';
|
|
10
10
|
|
|
11
11
|
const JAVA_BUILD_FILE_GLOBS = ['build.gradle', 'build.gradle.kts', 'pom.xml'];
|
|
@@ -423,14 +423,12 @@ export const adapter = {
|
|
|
423
423
|
if (!srcRoot) {
|
|
424
424
|
messages.push({ level: 'info', code: 'no-src-main-java', message: buildFiles.length > 0 ? 'found a build file, but none has a sibling src/main/java' : 'src/main/java does not exist' });
|
|
425
425
|
} else {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (!rgOk) {
|
|
433
|
-
messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it and will throw, not degrade, if it is missing' });
|
|
426
|
+
// D-zero-config-scan: traced live -- this does NOT throw at scan time as the message used
|
|
427
|
+
// to claim. detectJavaSpringRoot() itself (and every other rg shell-out at detect() time)
|
|
428
|
+
// is wrapped in a blanket try/catch that returns [] on failure, so a missing `rg` makes
|
|
429
|
+
// this adapter silently detect nothing (degrades to generic-grep) rather than crashing.
|
|
430
|
+
if (!binaryAvailable('rg')) {
|
|
431
|
+
messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it for file discovery and silently detects nothing without it (degrades to the generic-grep fallback), it does not throw' });
|
|
434
432
|
}
|
|
435
433
|
}
|
|
436
434
|
// D-openapi-extraction-hint: `contract emit --openapi-file` (A1-A12) is where real accuracy
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { execFileSync } from 'node:child_process';
|
|
15
|
-
import { lineNumberAt, listRgFiles as sharedListRgFiles, byShallowestThenName } from '../text-util.mjs';
|
|
15
|
+
import { lineNumberAt, listRgFiles as sharedListRgFiles, byShallowestThenName, binaryAvailable } from '../text-util.mjs';
|
|
16
16
|
|
|
17
17
|
const PROJECT_FILE_GLOBS = ['pyproject.toml', 'requirements*.txt'];
|
|
18
18
|
const EXCLUDE_GLOBS = ['!**/.venv/**', '!**/site-packages/**', '!**/node_modules/**', '!**/__pycache__/**'];
|
|
@@ -489,14 +489,12 @@ export const adapter = {
|
|
|
489
489
|
})) {
|
|
490
490
|
messages.push({ level: 'info', code: 'fastapi-not-a-dependency', message: `found ${depFiles.length} Python project file(s), but none declare a fastapi dependency` });
|
|
491
491
|
}
|
|
492
|
-
|
|
493
|
-
try
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
if (!rgOk) {
|
|
499
|
-
messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it and will throw, not degrade, if it is missing' });
|
|
492
|
+
// D-zero-config-scan: traced live -- this does NOT throw at scan time as the message used to
|
|
493
|
+
// claim. detectPythonFastApiRoot()'s own rg shell-out is wrapped in a blanket try/catch that
|
|
494
|
+
// returns [] on failure, so a missing `rg` makes this adapter silently detect nothing
|
|
495
|
+
// (degrades to generic-grep) rather than crashing.
|
|
496
|
+
if (!binaryAvailable('rg')) {
|
|
497
|
+
messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it for file discovery and silently detects nothing without it (degrades to the generic-grep fallback), it does not throw' });
|
|
500
498
|
}
|
|
501
499
|
// D-openapi-extraction-hint: unlike java-spring, this adapter's own capabilities already
|
|
502
500
|
// declare `api.operations: false` (FastAPI assigns operationIds at runtime) -- --openapi-file
|
|
Binary file
|
package/scanners/index.mjs
CHANGED
|
@@ -182,7 +182,12 @@ export function computeDbDrift(liveTables, relatedModules) {
|
|
|
182
182
|
// every other env-var-driven input in this codebase is resolved at the bin/bskel.mjs layer, never
|
|
183
183
|
// inside a "pure" lib/scanner function), and keeps this function synchronous (Plane C's real I/O
|
|
184
184
|
// is `await`ed by the caller before ever calling this).
|
|
185
|
-
|
|
185
|
+
//
|
|
186
|
+
// D-zero-config-scan: `rgAvailable` is the SAME kind of CLI-boundary-resolved input as `dbSchema`
|
|
187
|
+
// -- bin/bskel.mjs computes it once via lib/doctor.mjs's binaryAvailable('rg') and hands it in as
|
|
188
|
+
// plain data. Defaults to `true` so every existing call site (this whole test suite included)
|
|
189
|
+
// stays byte-for-byte unchanged.
|
|
190
|
+
export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, adapters = ADAPTERS, rgAvailable = true }) {
|
|
186
191
|
const detections = adapters
|
|
187
192
|
.map((a) => ({ a, d: a.detect(repoRoot) }))
|
|
188
193
|
.filter(({ d }) => d != null)
|
|
@@ -221,26 +226,73 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
|
|
|
221
226
|
// than crashing -- the gate token still falls back to hashing the report itself.
|
|
222
227
|
const filesRead = result.filesRead ?? [];
|
|
223
228
|
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
229
|
+
// D-zero-config-scan: an empty `terms` array is how bin/bskel.mjs's cmdScan signals its new
|
|
230
|
+
// zero-flag "inventory" mode (neither --terms nor --feature was given) -- every detected module
|
|
231
|
+
// is listed exactly as the adapter found it, with NO term-matching/scoring/collision-detection
|
|
232
|
+
// performed. Faking `score: 0` here would be actively misleading -- 0 already means "scored,
|
|
233
|
+
// found nothing" in the existing contract -- so each module is built as an explicit allowlist
|
|
234
|
+
// (never `{...m}`), which guarantees `score`/`evidence`/`capped_signals` are genuinely ABSENT
|
|
235
|
+
// from the object, not merely `undefined`, and stops any future adapter-internal field from
|
|
236
|
+
// leaking into this report shape by accident.
|
|
237
|
+
const isInventory = terms.length === 0;
|
|
238
|
+
let relatedModules;
|
|
239
|
+
let collisions;
|
|
240
|
+
let verdict;
|
|
241
|
+
if (isInventory) {
|
|
242
|
+
relatedModules = modules
|
|
243
|
+
.map((m) => ({ module: m.module, controllers: m.controllers, entities: m.entities, enums: m.enums, dtos: m.dtos }))
|
|
244
|
+
.sort((a, b) => a.module.localeCompare(b.module)); // deterministic -- no score to sort by
|
|
245
|
+
collisions = []; // not "nothing collides" -- nothing was CHECKED. See the disclaimer below.
|
|
246
|
+
verdict = 'inventory';
|
|
247
|
+
} else {
|
|
248
|
+
// O6: score alone isn't a deterministic sort key -- two modules tying on score fall back to
|
|
249
|
+
// whatever order they were already in, which traces back to non-deterministic rg discovery
|
|
250
|
+
// order in the adapters above (mitigated there too, but a second determinism layer here is
|
|
251
|
+
// cheap and doesn't depend on every adapter getting it right). Module name is a stable,
|
|
252
|
+
// meaningful secondary key.
|
|
253
|
+
const scored = modules
|
|
254
|
+
.map((m) => {
|
|
255
|
+
const { score, evidence, cappedSignals } = scoreModule(m, terms);
|
|
256
|
+
return { ...m, score, evidence, capped_signals: cappedSignals };
|
|
257
|
+
})
|
|
258
|
+
.sort((a, b) => b.score - a.score || a.module.localeCompare(b.module));
|
|
235
259
|
|
|
236
|
-
|
|
237
|
-
|
|
260
|
+
relatedModules = scored.filter((m) => m.score > 0);
|
|
261
|
+
collisions = relatedModules.filter((m) => m.score >= COLLISION_THRESHOLD);
|
|
238
262
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
263
|
+
verdict = 'greenfield';
|
|
264
|
+
if (collisions.length > 0) verdict = 'collision';
|
|
265
|
+
else if (relatedModules.length > 0) verdict = 'adjacent';
|
|
266
|
+
}
|
|
242
267
|
|
|
243
268
|
const unknowns = [];
|
|
269
|
+
// D-zero-config-scan: the FIRST unknowns entry in inventory mode -- visible in --json too, not
|
|
270
|
+
// just prose a human might skim past -- so nothing downstream (human or agent) backfills a
|
|
271
|
+
// relevance/collision judgment this report never made. See D-greenfield-parameters's safe/
|
|
272
|
+
// unsafe line: every module/controller/entity listed below is a real, observed fact about this
|
|
273
|
+
// repo; this disclaimer is what keeps "here's what exists" from being read as "here's what's
|
|
274
|
+
// safe to build".
|
|
275
|
+
if (isInventory) {
|
|
276
|
+
unknowns.push(
|
|
277
|
+
'this is an unscored inventory of every module this adapter found -- no term-matching, ' +
|
|
278
|
+
'relevance scoring, or collision/greenfield classification was performed against any ' +
|
|
279
|
+
'specific feature idea. Re-run with --terms <keywords> or --feature <id> to check a ' +
|
|
280
|
+
'specific idea against this repo before treating anything here as "safe" or "colliding".',
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
// D-zero-config-scan: without `rg`, every real adapter's detect() silently degrades (a blanket
|
|
284
|
+
// try/catch around the rg shell-out returns [] on failure -- see scanners/text-util.mjs's
|
|
285
|
+
// listRgFiles()), so a real Spring/FastAPI/Express repo can look indistinguishable from one
|
|
286
|
+
// this tool genuinely doesn't recognize. `rg_available` (always present on the returned report,
|
|
287
|
+
// both modes) is the machine-checkable signal; this is the human-readable one.
|
|
288
|
+
if (!rgAvailable) {
|
|
289
|
+
unknowns.push(
|
|
290
|
+
'ripgrep (`rg`) was not found on PATH -- every real scanner adapter depends on it for file ' +
|
|
291
|
+
'discovery, so a low-confidence or apparently-empty result here may simply mean `rg` is ' +
|
|
292
|
+
'missing, not that this repo lacks recognizable backend structure. Install it (`brew ' +
|
|
293
|
+
'install ripgrep`) and re-run, or run `bskel doctor` for full diagnostics.',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
244
296
|
if (!includeDb) {
|
|
245
297
|
unknowns.push('DB not scanned (Plane C is opt-in via --db --database-url-env <NAME>) -- pass --db to scan migration files, add --database-url-env for live introspection too. See A4 in CATALOG.md.');
|
|
246
298
|
} else if (!dbSchema?.live) {
|
|
@@ -271,6 +323,7 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
|
|
|
271
323
|
confidence,
|
|
272
324
|
api_surface_source: apiSurfaceSource,
|
|
273
325
|
verdict,
|
|
326
|
+
rg_available: rgAvailable,
|
|
274
327
|
path_prefix_signals: pathPrefixSignals,
|
|
275
328
|
related_modules: relatedModules,
|
|
276
329
|
collisions,
|
package/scanners/render.mjs
CHANGED
|
@@ -50,13 +50,22 @@ export function renderScanMarkdown(report) {
|
|
|
50
50
|
lines.push(`**Verdict**: \`${report.verdict}\``);
|
|
51
51
|
lines.push('');
|
|
52
52
|
|
|
53
|
+
// D-zero-config-scan: inventory mode has no term/collision semantics -- "greenfield for these
|
|
54
|
+
// terms" would be a lie when there were no terms to begin with, and a scored module's heading
|
|
55
|
+
// would dangle a `bskel scan explain` pointer that mode can never actually serve (it requires
|
|
56
|
+
// --feature, structurally unreachable here).
|
|
57
|
+
const isInventory = report.verdict === 'inventory';
|
|
53
58
|
if (report.related_modules.length === 0) {
|
|
54
|
-
lines.push(
|
|
59
|
+
lines.push(isInventory
|
|
60
|
+
? `No modules detected by the \`${report.adapter}\` adapter in this repo.`
|
|
61
|
+
: 'No related modules found -- greenfield for these terms.');
|
|
55
62
|
} else {
|
|
56
|
-
lines.push('## Related modules');
|
|
63
|
+
lines.push(isInventory ? '## Modules found' : '## Related modules');
|
|
57
64
|
lines.push('');
|
|
58
65
|
for (const mod of report.related_modules) {
|
|
59
|
-
lines.push(
|
|
66
|
+
lines.push(isInventory
|
|
67
|
+
? `### \`${mod.module}\``
|
|
68
|
+
: `### \`${mod.module}\` (score: ${mod.score} -- run \`bskel scan explain ${mod.module}\` for the evidence breakdown)`);
|
|
60
69
|
for (const c of mod.controllers) {
|
|
61
70
|
lines.push(`- Controller \`${c.className}\` (base path \`${c.basePath}\`), ${c.endpoints.length} endpoint(s):`);
|
|
62
71
|
for (const ep of c.endpoints) {
|
package/scanners/text-util.mjs
CHANGED
|
@@ -31,3 +31,25 @@ export function byShallowestThenName(a, b) {
|
|
|
31
31
|
const depthB = b.split(path.sep).length;
|
|
32
32
|
return depthA !== depthB ? depthA - depthB : a.localeCompare(b);
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// D-zero-config-scan: the pure "is this binary on PATH" check, deliberately living HERE (a
|
|
36
|
+
// leaf module with zero project-internal imports) rather than in lib/doctor.mjs, where it was
|
|
37
|
+
// first written. lib/doctor.mjs imports lib/verify.mjs, which imports scanners/registry.mjs --
|
|
38
|
+
// and every adapter file is dynamically import()ed BY registry.mjs's own top-level await. An
|
|
39
|
+
// adapter importing lib/doctor.mjs would close that cycle (adapter -> lib/doctor.mjs ->
|
|
40
|
+
// lib/verify.mjs -> scanners/registry.mjs -> [import()s the adapter]) and registry.mjs's OWN
|
|
41
|
+
// header comment already flags exactly this risk ("no adapter imports anything from this
|
|
42
|
+
// module" -- true of registry.mjs itself, but lib/doctor.mjs transitively reaches it). Found
|
|
43
|
+
// live: the first version of this change put binaryAvailable() in lib/doctor.mjs and every
|
|
44
|
+
// adapter import of it hung the whole CLI (a real "Detected unsettled top-level await" Node
|
|
45
|
+
// warning, reproduced against a real fixture repo, not a hypothetical). lib/doctor.mjs now
|
|
46
|
+
// re-exports this for its own existing callers (bin/bskel.mjs, its own binaryCheck()) --
|
|
47
|
+
// nothing outside this file needs to know it moved.
|
|
48
|
+
export function binaryAvailable(name, execFn = execFileSync) {
|
|
49
|
+
try {
|
|
50
|
+
execFn(name, ['--version'], { stdio: 'pipe' });
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|