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/lib/gate-definitions.mjs
CHANGED
|
@@ -295,6 +295,32 @@ export const GATE_DEFINITIONS = Object.freeze({
|
|
|
295
295
|
return inputs;
|
|
296
296
|
},
|
|
297
297
|
},
|
|
298
|
+
// D-business-rules (R7): a feature's compiled business rules. Its own gate rather than a
|
|
299
|
+
// widening of `dependencies` -- the same call cross_feature/dependencies/patch_transactions/
|
|
300
|
+
// conformance each made rather than overloading a neighbour, and the two genuinely differ:
|
|
301
|
+
// a dependency is an edge between two fields, a rule is a constraint checked against one
|
|
302
|
+
// feature's own contract. REQUIRED_WHEN_PRESENT because most features will never declare one.
|
|
303
|
+
//
|
|
304
|
+
// Positioned between `dependencies` and `handles`: after dependencies because a derived-field
|
|
305
|
+
// rule references a declared dependency edge, before handles because that is where generated
|
|
306
|
+
// code appears.
|
|
307
|
+
//
|
|
308
|
+
// contract_hash is an input, not just the two rules files: every pointer, scalar type, and
|
|
309
|
+
// enum state in a compiled rule was verified against the contract at compile time, so
|
|
310
|
+
// re-emitting the contract can invalidate a rule that still looks fine on its own. Without
|
|
311
|
+
// this token a contract change could silently leave a rule enforcing against a field that no
|
|
312
|
+
// longer exists. (`contract` is itself a REQUIRED gate, so this is defence in depth, not the
|
|
313
|
+
// only line -- the same relationship `handles`'s own contract_hash token already has.)
|
|
314
|
+
rules: {
|
|
315
|
+
name: 'rules',
|
|
316
|
+
scope: SCOPE.FEATURE,
|
|
317
|
+
verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
|
|
318
|
+
recompute: (root, featureId) => ({
|
|
319
|
+
rules_source_hash: sha256File(specPath(root, featureId, 'rules.yaml')),
|
|
320
|
+
rules_compiled_hash: sha256File(specPath(root, featureId, 'rules', `${featureId}.rules.json`)),
|
|
321
|
+
contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
|
|
322
|
+
}),
|
|
323
|
+
},
|
|
298
324
|
// Staleness = the generated Java (or the contract it was generated from) has moved since
|
|
299
325
|
// emit -- NOT "does the migration still match the DB schema" (unknowable without a live DB
|
|
300
326
|
// connection this tool deliberately never opens on its own, see D-migration-scope). Note
|
|
@@ -403,7 +429,7 @@ export const GATE_DEFINITIONS = Object.freeze({
|
|
|
403
429
|
// test/gate-definitions.test.mjs asserts this stays exactly in sync with GATE_DEFINITIONS' own
|
|
404
430
|
// key set, so a gate added to one and not the other fails loudly instead of silently vanishing
|
|
405
431
|
// from `bskel verify` the way `stack` did before this module existed.
|
|
406
|
-
export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'handles', 'stack', 'patch_transactions', 'conformance']);
|
|
432
|
+
export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'rules', 'handles', 'stack', 'patch_transactions', 'conformance']);
|
|
407
433
|
|
|
408
434
|
export function getGateDefinition(name) {
|
|
409
435
|
return Object.hasOwn(GATE_DEFINITIONS, name) ? GATE_DEFINITIONS[name] : null;
|
package/lib/workflow.mjs
CHANGED
|
@@ -44,6 +44,11 @@ const ESTABLISH_COMMAND = {
|
|
|
44
44
|
// directly on the first real stale occurrence and throws a raw TypeError instead of a clean
|
|
45
45
|
// stale report.
|
|
46
46
|
dependencies: (id) => `bskel dependency declare --feature ${id} --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."`,
|
|
47
|
+
// D-business-rules (R7): wired in the SAME commit as the `rules` gate itself, per the repeated
|
|
48
|
+
// warnings above -- this exact gap has now been found live three times (dependencies,
|
|
49
|
+
// conformance, and the cross_feature near-miss) and is the single most reliably-recurring
|
|
50
|
+
// mistake in this file.
|
|
51
|
+
rules: (id) => `bskel rules check --feature ${id}`,
|
|
47
52
|
handles: (id) => `bskel handles plan --feature ${id} # then: bskel handles emit --feature ${id}`,
|
|
48
53
|
stack: () => 'bskel stack apply --choice <id> --apply',
|
|
49
54
|
// D-patch-transactions: added in the SAME commit as the gate itself -- per the
|
|
@@ -78,11 +83,21 @@ function awaitingDispositionCommand(gateName, featureId) {
|
|
|
78
83
|
// been exercised through `next` recommending the real `conformance`-gate command, so a real
|
|
79
84
|
// `mutating: false` misclassification on a command that actually writes files + passes a gate went
|
|
80
85
|
// unnoticed) -- see D-field-dependency's COST section in DECISIONS.md.
|
|
86
|
+
// D-pattern-accrual: `bskel new`/`bskel pattern list|show|suggest` were deliberately checked
|
|
87
|
+
// against this same gap (per this comment's own warning above) and found NOT to need an entry
|
|
88
|
+
// here or in ESTABLISH_COMMAND -- none of the four corresponds to a GATE_NAMES gate (`new` predates
|
|
89
|
+
// any feature/gate existing at all; the three `pattern` verbs are a repo-independent, cross-project
|
|
90
|
+
// read surface `next`/`status` never has reason to recommend). `handles audit` (O7) is the existing
|
|
91
|
+
// precedent for a read-only command correctly staying out of this list.
|
|
81
92
|
const MUTATING_PREFIXES = [
|
|
82
93
|
'bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive',
|
|
83
94
|
'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit',
|
|
84
95
|
'bskel handles plan', 'bskel stack apply', 'bskel observe emit', 'bskel observe import',
|
|
85
96
|
'bskel patch propose', 'bskel patch approve', 'bskel patch apply', 'bskel patch rollback',
|
|
97
|
+
// D-business-rules (R7): `rules check` writes the compiled artifact and passes its gate;
|
|
98
|
+
// `rules emit` writes generated source. `rules list`/`rules explain` are read-only and
|
|
99
|
+
// correctly absent -- the same per-verb split `pattern list|show|suggest` was checked against.
|
|
100
|
+
'bskel rules check', 'bskel rules emit',
|
|
86
101
|
];
|
|
87
102
|
|
|
88
103
|
// Exported (not just inlined into action()) so lib/workflow.mjs's own test suite can assert the
|
package/new/index.mjs
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
// parameters it accepts and which it explicitly refuses), and deliberately still a plain object.
|
|
10
10
|
// The reason two first-party stacks don't justify a registry hasn't changed just because each entry
|
|
11
11
|
// grew three fields.
|
|
12
|
+
//
|
|
13
|
+
// D-pattern-accrual: a FOURTH field, `reusableParams` -- the subset of `acceptedParams` worth
|
|
14
|
+
// persisting to a user-owned pattern store when they opt in via `--record-pattern`. Deliberately a
|
|
15
|
+
// subset, not `acceptedParams` itself: per-project identity (`name`/`description`/`project-version`,
|
|
16
|
+
// spring's `artifact-id`/`package-name`) describes THIS project, not a reusable convention, and
|
|
17
|
+
// storing project names in a database buys nothing (see D-pattern-accrual's own EXIT for the full
|
|
18
|
+
// list and why each exclusion is drawn).
|
|
12
19
|
import { scaffoldSpring } from './spring.mjs';
|
|
13
20
|
import { scaffoldFastapi } from './fastapi.mjs';
|
|
14
21
|
|
|
@@ -39,6 +46,10 @@ export const STACKS = Object.freeze({
|
|
|
39
46
|
'dependencies', 'add-dependencies',
|
|
40
47
|
]),
|
|
41
48
|
refusedParams: SPRING_REFUSED_PARAMS,
|
|
49
|
+
// D-pattern-accrual: `group-id` is the one per-project-identity-shaped field kept in
|
|
50
|
+
// (unlike `artifact-id`/`package-name`) -- an organization's group id is itself a reusable
|
|
51
|
+
// convention (`com.ourco`, applied to every project), not a name unique to this one project.
|
|
52
|
+
reusableParams: Object.freeze(['java-version', 'packaging', 'dependencies', 'add-dependencies', 'group-id']),
|
|
42
53
|
}),
|
|
43
54
|
fastapi: Object.freeze({
|
|
44
55
|
id: 'fastapi',
|
|
@@ -46,9 +57,18 @@ export const STACKS = Object.freeze({
|
|
|
46
57
|
requiresNetwork: false,
|
|
47
58
|
acceptedParams: Object.freeze([...COMMON_PARAMS, 'python-version', 'port', 'license', 'database']),
|
|
48
59
|
refusedParams: Object.freeze({}),
|
|
60
|
+
reusableParams: Object.freeze(['python-version', 'port', 'license', 'database']),
|
|
49
61
|
}),
|
|
50
62
|
});
|
|
51
63
|
|
|
64
|
+
// D-pattern-accrual: the one place `bskel new`'s best-effort recording call and `bskel pattern
|
|
65
|
+
// suggest` both look up which flags are worth persisting for a stack -- never re-derived, never a
|
|
66
|
+
// hand-copied list at either call site.
|
|
67
|
+
export function reusableParamsFor(stackId) {
|
|
68
|
+
const stack = STACKS[stackId];
|
|
69
|
+
return stack ? stack.reusableParams : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
// Every parameter any stack knows about -- the set cmdNew checks a passed flag against to decide
|
|
53
73
|
// "wrong stack" vs. "not a stack parameter at all". Derived, never a hand-maintained third list.
|
|
54
74
|
export const ALL_STACK_PARAMS = Object.freeze([...new Set(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-skeleton",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"handles/",
|
|
27
27
|
"new/",
|
|
28
28
|
"stack/",
|
|
29
|
+
"patterns/",
|
|
30
|
+
"rules/",
|
|
29
31
|
"schemas/",
|
|
30
32
|
"scripts/preflight-base-ref.sh",
|
|
31
33
|
"LICENSE",
|
|
@@ -38,6 +40,8 @@
|
|
|
38
40
|
"test:java-compile": "node scripts/java-compile-smoke.mjs",
|
|
39
41
|
"test:python-import": "node scripts/python-import-smoke.mjs",
|
|
40
42
|
"test:db-introspect": "node scripts/db-introspect-smoke.mjs",
|
|
43
|
+
"test:pattern-db": "node scripts/pattern-db-smoke.mjs",
|
|
44
|
+
"test:db-erd": "node scripts/db-erd-smoke.mjs",
|
|
41
45
|
"test:registry-coverage": "node scripts/handles-registry-coverage-smoke.mjs",
|
|
42
46
|
"test:ddl-apply": "node scripts/ddl-apply-smoke.mjs",
|
|
43
47
|
"test:cross-feature-fk": "node scripts/cross-feature-fk-smoke.mjs",
|
|
@@ -48,7 +52,7 @@
|
|
|
48
52
|
"test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs",
|
|
49
53
|
"test:shadow-validation": "node scripts/shadow-validation-smoke.mjs",
|
|
50
54
|
"test:oracle-corpus": "node scripts/shadow-validation-smoke.mjs --manifest test/fixtures/oracle-manifest.json",
|
|
51
|
-
"test:all-smoke": "npm test && npm run test:pack && npm run test:java-compile && npm run test:java-integration && npm run test:python-import && npm run test:python-integration && npm run test:db-introspect && npm run test:registry-coverage && npm run test:ddl-apply && npm run test:cross-feature-fk && npm run test:java-ast && npm run test:typescript-compile"
|
|
55
|
+
"test:all-smoke": "npm test && npm run test:pack && npm run test:java-compile && npm run test:java-integration && npm run test:python-import && npm run test:python-integration && npm run test:db-introspect && npm run test:pattern-db && npm run test:db-erd && npm run test:registry-coverage && npm run test:ddl-apply && npm run test:cross-feature-fk && npm run test:java-ast && npm run test:typescript-compile"
|
|
52
56
|
},
|
|
53
57
|
"dependencies": {
|
|
54
58
|
"ajv": "^8.20.0",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- D-pattern-accrual: the sbf_pattern table `bskel new --record-pattern` writes to and
|
|
2
|
+
-- `bskel pattern list/show/suggest` read from. This is a database YOU own for YOUR OWN
|
|
3
|
+
-- projects' conventions -- bskel never creates it automatically (D-migration-scope: the same
|
|
4
|
+
-- "detect the missing table, name this exact file, never auto-DDL" posture handles/migration.sql.tmpl
|
|
5
|
+
-- already established for sbf_handle/sbf_handle_snapshot). Run this by hand, once, against
|
|
6
|
+
-- whatever Postgres database --pattern-database-url-env will point at.
|
|
7
|
+
--
|
|
8
|
+
-- `params` is JSONB, not one column per parameter, because the accepted parameter set differs
|
|
9
|
+
-- per stack (new/index.mjs's reusableParams) and grows independently of this table's own schema --
|
|
10
|
+
-- adding a stack, or widening a stack's reusableParams, needs zero migration here.
|
|
11
|
+
CREATE TABLE IF NOT EXISTS sbf_pattern (
|
|
12
|
+
pattern_id UUID PRIMARY KEY,
|
|
13
|
+
stack TEXT NOT NULL,
|
|
14
|
+
params JSONB NOT NULL,
|
|
15
|
+
recorded_at TIMESTAMPTZ NOT NULL
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
CREATE INDEX IF NOT EXISTS sbf_pattern_stack_idx ON sbf_pattern (stack);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// D-pattern-accrual: a database the USER owns for THEIR OWN past `bskel new` invocations --
|
|
2
|
+
// never bskel's own repo state, never a shared/external source (see D-pattern-accrual's own WHY
|
|
3
|
+
// for the line this draws against `.bskel/config.yml`'s standing refusal). Reuses A4/D-db-schema-plane's
|
|
4
|
+
// own `pg`/connection conventions unchanged: one `pg.Client`, sequential queries, `BEGIN TRANSACTION
|
|
5
|
+
// READ ONLY` on every read path (structural defense-in-depth, same as scanners/db/introspect.mjs and
|
|
6
|
+
// handles/audit.mjs). The one write path here (recordPattern) is a plain INSERT, no transaction
|
|
7
|
+
// needed for a single statement.
|
|
8
|
+
import pg from 'pg';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
|
|
11
|
+
|
|
12
|
+
const { Client } = pg;
|
|
13
|
+
|
|
14
|
+
// Postgres error code for "relation does not exist" -- the real, expected shape when
|
|
15
|
+
// patterns/schema.sql was never applied to this database (D-migration-scope: bskel never applies
|
|
16
|
+
// it automatically). Byte-identical convention to handles/audit.mjs's own isMissingHandleTables.
|
|
17
|
+
const UNDEFINED_TABLE = '42P01';
|
|
18
|
+
|
|
19
|
+
export function isMissingPatternTable(err) {
|
|
20
|
+
return err?.code === UNDEFINED_TABLE;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function rowToRecord(row) {
|
|
24
|
+
return {
|
|
25
|
+
schema: 'sbf.pattern/1',
|
|
26
|
+
pattern_id: row.pattern_id,
|
|
27
|
+
stack: row.stack,
|
|
28
|
+
params: row.params,
|
|
29
|
+
recorded_at: row.recorded_at instanceof Date ? row.recorded_at.toISOString() : row.recorded_at,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function validateRecord(record) {
|
|
34
|
+
const { ok, errors } = validateAgainstSchema('pattern-record.schema.json', record);
|
|
35
|
+
if (!ok) {
|
|
36
|
+
throw new Error(`refusing to write invalid pattern record:\n${formatSchemaErrors(errors).join('\n')}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// `params` here is already the raw, user-typed CLI flag values for whatever
|
|
41
|
+
// `new/index.mjs`'s reusableParamsFor(stack) names -- the caller (cmdNew) owns filtering down to
|
|
42
|
+
// that set; this function only validates the resulting record's shape and writes it. Best-effort
|
|
43
|
+
// from the CALLER's perspective (bin/bskel.mjs never lets a failure here fail `bskel new` itself) --
|
|
44
|
+
// this function itself throws on any real failure, same as every other patterns/store.mjs function.
|
|
45
|
+
export async function recordPattern({ connectionString, stack, params }) {
|
|
46
|
+
const record = {
|
|
47
|
+
schema: 'sbf.pattern/1',
|
|
48
|
+
pattern_id: randomUUID(),
|
|
49
|
+
stack,
|
|
50
|
+
params,
|
|
51
|
+
recorded_at: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
validateRecord(record);
|
|
54
|
+
const client = new Client({ connectionString });
|
|
55
|
+
await client.connect();
|
|
56
|
+
try {
|
|
57
|
+
await client.query(
|
|
58
|
+
'INSERT INTO sbf_pattern (pattern_id, stack, params, recorded_at) VALUES ($1, $2, $3, $4)',
|
|
59
|
+
[record.pattern_id, record.stack, JSON.stringify(record.params), record.recorded_at],
|
|
60
|
+
);
|
|
61
|
+
} finally {
|
|
62
|
+
await client.end();
|
|
63
|
+
}
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function listPatterns({ connectionString, stack = null }) {
|
|
68
|
+
const client = new Client({ connectionString });
|
|
69
|
+
await client.connect();
|
|
70
|
+
try {
|
|
71
|
+
await client.query('BEGIN TRANSACTION READ ONLY');
|
|
72
|
+
const sql = stack
|
|
73
|
+
? 'SELECT pattern_id, stack, params, recorded_at FROM sbf_pattern WHERE stack = $1 ORDER BY recorded_at DESC'
|
|
74
|
+
: 'SELECT pattern_id, stack, params, recorded_at FROM sbf_pattern ORDER BY recorded_at DESC';
|
|
75
|
+
const res = await client.query(sql, stack ? [stack] : []);
|
|
76
|
+
await client.query('COMMIT');
|
|
77
|
+
return res.rows.map(rowToRecord);
|
|
78
|
+
} finally {
|
|
79
|
+
await client.end();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function getPattern({ connectionString, patternId }) {
|
|
84
|
+
const client = new Client({ connectionString });
|
|
85
|
+
await client.connect();
|
|
86
|
+
try {
|
|
87
|
+
await client.query('BEGIN TRANSACTION READ ONLY');
|
|
88
|
+
const res = await client.query(
|
|
89
|
+
'SELECT pattern_id, stack, params, recorded_at FROM sbf_pattern WHERE pattern_id = $1',
|
|
90
|
+
[patternId],
|
|
91
|
+
);
|
|
92
|
+
await client.query('COMMIT');
|
|
93
|
+
return res.rows.length > 0 ? rowToRecord(res.rows[0]) : null;
|
|
94
|
+
} finally {
|
|
95
|
+
await client.end();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Pure -- no DB access, unit-testable directly (test/pattern-store.test.mjs). For each name in
|
|
100
|
+
// `reusableParams`, ranks every distinct value actually seen across `records` by how many records
|
|
101
|
+
// carried it, denominator always `records.length` (the full recorded-run count for this stack,
|
|
102
|
+
// NOT just the runs that happened to set this particular param -- an omitted param is real
|
|
103
|
+
// information, not missing data). Never collapses to one "best" value -- see D-pattern-accrual's
|
|
104
|
+
// WHY for the confidence-label posture this mirrors (D-cross-feature-collision).
|
|
105
|
+
export function summarizePatternFrequency(records, reusableParams) {
|
|
106
|
+
const total = records.length;
|
|
107
|
+
const summary = [];
|
|
108
|
+
for (const param of reusableParams) {
|
|
109
|
+
const counts = new Map();
|
|
110
|
+
for (const record of records) {
|
|
111
|
+
const value = record.params[param];
|
|
112
|
+
if (value == null) continue;
|
|
113
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
if (counts.size === 0) continue;
|
|
116
|
+
const values = [...counts.entries()]
|
|
117
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
118
|
+
.map(([value, count]) => ({ value, count, total }));
|
|
119
|
+
summary.push({ param, values });
|
|
120
|
+
}
|
|
121
|
+
return summary;
|
|
122
|
+
}
|