@sdd-method/author-cli 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/NOTICE +17 -0
- package/README.md +6 -2
- package/dist/author-bin.js +8 -0
- package/dist/author-program.js +99 -0
- package/dist/lib/adr-origin.js +59 -0
- package/dist/lib/agent-harness.js +37 -0
- package/dist/lib/api-surface/__fixtures__/serverless-repo/serverless/serverless.yml +18 -0
- package/dist/lib/api-surface/analyzers/library.js +95 -0
- package/dist/lib/api-surface/analyzers/serverless.js +336 -0
- package/dist/lib/api-surface/analyzers/spring.js +237 -0
- package/dist/lib/api-surface/analyzers/terraform.js +109 -0
- package/dist/lib/api-surface/detect.js +25 -0
- package/dist/lib/api-surface/emit.js +109 -0
- package/dist/lib/api-surface/file-set.js +43 -0
- package/dist/lib/api-surface/index.js +134 -0
- package/dist/lib/api-surface/types.js +17 -0
- package/dist/lib/briefing/index.js +13 -0
- package/dist/lib/briefing/render.js +132 -0
- package/dist/lib/briefing/variables.js +92 -0
- package/dist/lib/catalogue/application-archetype/vocabulary.js +177 -0
- package/dist/lib/catalogue/archetype/maps/android.yaml +66 -0
- package/dist/lib/catalogue/archetype/maps/swiftui.yaml +58 -0
- package/dist/lib/catalogue/archetype/maps/uikit.yaml +48 -0
- package/dist/lib/catalogue/archetype/maps/web-generic.yaml +86 -0
- package/dist/lib/catalogue/archetype/openui-archetype-vocabulary.yaml +46 -0
- package/dist/lib/catalogue/archetype/resolver.js +103 -0
- package/dist/lib/catalogue/archetype/wireframe.js +418 -0
- package/dist/lib/catalogue/baseline-check.js +151 -0
- package/dist/lib/catalogue/build.js +1502 -0
- package/dist/lib/catalogue/builders/adr-supersedes.js +103 -0
- package/dist/lib/catalogue/builders/adrs.js +222 -0
- package/dist/lib/catalogue/builders/application-conformance-coverage.js +47 -0
- package/dist/lib/catalogue/builders/application-data-plane-bindings.js +115 -0
- package/dist/lib/catalogue/builders/c4-components.js +229 -0
- package/dist/lib/catalogue/builders/c4-containers.js +240 -0
- package/dist/lib/catalogue/builders/c4-entity-rollups.js +87 -0
- package/dist/lib/catalogue/builders/c4-external-systems.js +194 -0
- package/dist/lib/catalogue/builders/c4-l1-candidates.js +107 -0
- package/dist/lib/catalogue/builders/c4-systems.js +98 -0
- package/dist/lib/catalogue/builders/capabilities.js +488 -0
- package/dist/lib/catalogue/builders/capability-domain-model.js +74 -0
- package/dist/lib/catalogue/builders/capability-edges.js +256 -0
- package/dist/lib/catalogue/builders/capability-spec-walker.js +99 -0
- package/dist/lib/catalogue/builders/capability-spec.js +348 -0
- package/dist/lib/catalogue/builders/container-dependency.js +183 -0
- package/dist/lib/catalogue/builders/contracts.js +402 -0
- package/dist/lib/catalogue/builders/data-items.js +344 -0
- package/dist/lib/catalogue/builders/data-store-mapping.js +113 -0
- package/dist/lib/catalogue/builders/domain-models.js +175 -0
- package/dist/lib/catalogue/builders/domains.js +119 -0
- package/dist/lib/catalogue/builders/edge-label-merge.js +19 -0
- package/dist/lib/catalogue/builders/entities-manifest.js +160 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +189 -0
- package/dist/lib/catalogue/builders/entity-persistence.js +102 -0
- package/dist/lib/catalogue/builders/entity-slug.js +47 -0
- package/dist/lib/catalogue/builders/event-catalog-walker.js +186 -0
- package/dist/lib/catalogue/builders/event-edges.js +327 -0
- package/dist/lib/catalogue/builders/events.js +168 -0
- package/dist/lib/catalogue/builders/evidence-class.js +43 -0
- package/dist/lib/catalogue/builders/evidence-target-repo.js +67 -0
- package/dist/lib/catalogue/builders/external-system-exemption.js +120 -0
- package/dist/lib/catalogue/builders/feature-contract.js +77 -0
- package/dist/lib/catalogue/builders/feature-domain-model.js +50 -0
- package/dist/lib/catalogue/builders/feature-grounding-walker.js +254 -0
- package/dist/lib/catalogue/builders/feature-grounding.js +391 -0
- package/dist/lib/catalogue/builders/feature-groups.js +63 -0
- package/dist/lib/catalogue/builders/feature-subscription-channels.js +228 -0
- package/dist/lib/catalogue/builders/feature-tree-walker.js +295 -0
- package/dist/lib/catalogue/builders/features.js +300 -0
- package/dist/lib/catalogue/builders/glossary-xref.js +378 -0
- package/dist/lib/catalogue/builders/glossary.js +204 -0
- package/dist/lib/catalogue/builders/implementations.js +214 -0
- package/dist/lib/catalogue/builders/index.js +64 -0
- package/dist/lib/catalogue/builders/integration-components.js +344 -0
- package/dist/lib/catalogue/builders/integrations.js +181 -0
- package/dist/lib/catalogue/builders/persona-c4system.js +149 -0
- package/dist/lib/catalogue/builders/persona-container.js +133 -0
- package/dist/lib/catalogue/builders/persona-journey-edges.js +133 -0
- package/dist/lib/catalogue/builders/personas.js +152 -0
- package/dist/lib/catalogue/builders/product-edges.js +106 -0
- package/dist/lib/catalogue/builders/product-platform-dependencies.js +405 -0
- package/dist/lib/catalogue/builders/products.js +162 -0
- package/dist/lib/catalogue/builders/quality-scores.js +635 -0
- package/dist/lib/catalogue/builders/repo-capability-exemption.js +61 -0
- package/dist/lib/catalogue/builders/repo-dependency.js +127 -0
- package/dist/lib/catalogue/builders/repo-edges.js +340 -0
- package/dist/lib/catalogue/builders/repositories.js +458 -0
- package/dist/lib/catalogue/builders/scaffolds.js +197 -0
- package/dist/lib/catalogue/builders/screen-tree.js +222 -0
- package/dist/lib/catalogue/builders/screens.js +396 -0
- package/dist/lib/catalogue/builders/service-capability-exemption.js +63 -0
- package/dist/lib/catalogue/builders/service-contract-consumes.js +186 -0
- package/dist/lib/catalogue/builders/service-contract.js +74 -0
- package/dist/lib/catalogue/builders/service-entity-tables.js +187 -0
- package/dist/lib/catalogue/builders/services.js +232 -0
- package/dist/lib/catalogue/builders/system-external-dependency.js +115 -0
- package/dist/lib/catalogue/builders/user-journeys.js +253 -0
- package/dist/lib/catalogue/canonical-schema.yaml +4117 -0
- package/dist/lib/catalogue/cli-version.js +10 -0
- package/dist/lib/catalogue/contract-id.js +49 -0
- package/dist/lib/catalogue/coverage-predicates.js +603 -0
- package/dist/lib/catalogue/coverage-render.js +552 -0
- package/dist/lib/catalogue/coverage-run.js +115 -0
- package/dist/lib/catalogue/csv-reader.js +97 -0
- package/dist/lib/catalogue/csv-writer.js +96 -0
- package/dist/lib/catalogue/data-plane-coherence.js +77 -0
- package/dist/lib/catalogue/ddl-generator.js +233 -0
- package/dist/lib/catalogue/dedupe-keyed-tables.js +0 -0
- package/dist/lib/catalogue/engine-validator.js +239 -0
- package/dist/lib/catalogue/ext-id.js +76 -0
- package/dist/lib/catalogue/feature-grounding-aliases-loader.js +65 -0
- package/dist/lib/catalogue/feature-repo-zero-resolution-summary.js +31 -0
- package/dist/lib/catalogue/finding-identity.js +183 -0
- package/dist/lib/catalogue/findings.js +46 -0
- package/dist/lib/catalogue/fk-validator.js +136 -0
- package/dist/lib/catalogue/freshness.js +70 -0
- package/dist/lib/catalogue/gate-checks.js +338 -0
- package/dist/lib/catalogue/graph-projection.js +346 -0
- package/dist/lib/catalogue/impact-run.js +228 -0
- package/dist/lib/catalogue/manifest-loader.js +435 -0
- package/dist/lib/catalogue/platform-scope.js +7 -0
- package/dist/lib/catalogue/population-checks.js +87 -0
- package/dist/lib/catalogue/relationship-semantics.js +66 -0
- package/dist/lib/catalogue/reverse-product-quality.js +99 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +871 -0
- package/dist/lib/catalogue/runway-vocabulary-loader.js +56 -0
- package/dist/lib/catalogue/scaffold-delta.js +261 -0
- package/dist/lib/catalogue/schema-compat.js +52 -0
- package/dist/lib/catalogue/schema-loader.js +87 -0
- package/dist/lib/catalogue/scores-coverage.js +573 -0
- package/dist/lib/catalogue/screen-validation-loader.js +89 -0
- package/dist/lib/catalogue/service-architecture-parser.js +193 -0
- package/dist/lib/catalogue/service-contract-aliases-loader.js +109 -0
- package/dist/lib/catalogue/staleness-verdicts.js +345 -0
- package/dist/lib/catalogue/traceability-loader.js +350 -0
- package/dist/lib/catalogue/validate.js +200 -0
- package/dist/lib/catalogue/version.js +18 -0
- package/dist/lib/conformance/binding.js +70 -0
- package/dist/lib/conformance/finding.js +69 -0
- package/dist/lib/conformance/index.js +578 -0
- package/dist/lib/context-pull/index.js +191 -0
- package/dist/lib/context-pull/resolve.js +70 -0
- package/dist/lib/context-pull/retired-mcp-setup.js +7 -0
- package/dist/lib/contracts-availability/index.js +267 -0
- package/dist/lib/generate-briefing/index.js +77 -0
- package/dist/lib/list/discover.js +100 -0
- package/dist/lib/list/format.js +84 -0
- package/dist/lib/list/index.js +16 -0
- package/dist/lib/lock-file.js +437 -0
- package/dist/lib/pack/log.js +18 -0
- package/dist/lib/pack/schema.js +121 -0
- package/dist/lib/pack/seed.js +182 -0
- package/dist/lib/pack/validate.js +49 -0
- package/dist/lib/package-version.js +92 -0
- package/dist/lib/plan/index.js +69 -0
- package/dist/lib/plan/read-sdd.js +102 -0
- package/dist/lib/plan/render-template.js +84 -0
- package/dist/lib/repo-kind.js +129 -0
- package/dist/lib/scaffold/domain.js +234 -0
- package/dist/lib/scaffold/registry.js +136 -0
- package/dist/lib/scripts.js +7 -0
- package/dist/lib/sdd-index/repo-config.js +245 -0
- package/dist/lib/sdd-reader.js +91 -0
- package/dist/lib/snapshot/advance.js +454 -0
- package/dist/lib/snapshot/diff.js +190 -0
- package/dist/lib/snapshot/fold.js +208 -0
- package/dist/lib/snapshot/land.js +178 -0
- package/dist/lib/snapshot/manifest.js +206 -0
- package/dist/lib/snapshot/targets.js +169 -0
- package/dist/lib/snapshot/verify.js +91 -0
- package/dist/lib/sync/briefing-merge.js +106 -0
- package/dist/lib/sync/bundle-cache.js +109 -0
- package/dist/lib/sync/yaml-block-merge.js +120 -0
- package/dist/lib/validate/aggregate.js +51 -0
- package/dist/lib/validate/bundle-coherence.js +74 -0
- package/dist/lib/validate/dispatch.js +243 -0
- package/dist/lib/validate/index.js +110 -0
- package/dist/lib/validate/plan.js +54 -0
- package/dist/lib/validate/runner.js +85 -0
- package/dist/lib/validate/yaml-parse.js +112 -0
- package/dist/maintain-version.json +4 -0
- package/dist/verbs/api-surface.js +71 -0
- package/dist/verbs/catalogue-check.js +28 -0
- package/dist/verbs/catalogue-verify.js +114 -0
- package/dist/verbs/conformance.js +34 -0
- package/dist/verbs/context.js +39 -0
- package/dist/verbs/contracts.js +34 -0
- package/dist/verbs/generate-briefing.js +29 -0
- package/dist/verbs/list.js +45 -0
- package/dist/verbs/pack-validate.js +17 -0
- package/dist/verbs/path-option.js +25 -0
- package/dist/verbs/plan.js +42 -0
- package/dist/verbs/render.js +87 -0
- package/dist/verbs/scaffold.js +83 -0
- package/dist/verbs/snapshot.js +134 -0
- package/dist/verbs/validate.js +48 -0
- package/dist/verify-program.js +122 -0
- package/dist/verify-version.json +4 -0
- package/package.json +32 -4
- package/scripts/validate-app-dependencies.sh +664 -0
- package/scripts/validate-app-docs.sh +199 -0
- package/scripts/validate-integration-docs.sh +505 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
// Quality-scores builder — emits the rubric-assessment catalogues (M-ADR 0205
|
|
2
|
+
// §4.5, schema 3.2.0):
|
|
3
|
+
// quality_scores, quality_score_dimensions
|
|
4
|
+
//
|
|
5
|
+
// Source: `orchestration/gate-registry/<rubric>-scores.yaml`. The authored
|
|
6
|
+
// surface stays YAML because a score is a judgment, authored where it asserts
|
|
7
|
+
// intent; these rows are DERIVED and never edited.
|
|
8
|
+
//
|
|
9
|
+
// Three shapes here are counter-intuitive enough that a builder written from
|
|
10
|
+
// the method's own registry TEMPLATES would get all three wrong. Each was
|
|
11
|
+
// measured across 15 method-side + 152 estate-side registry files (the Phase-B
|
|
12
|
+
// B0 survey, 2026-08-06), and each is guarded by a control:
|
|
13
|
+
//
|
|
14
|
+
// 1. TWO AUTHORED DIALECTS. All fifteen method templates specify a nested
|
|
15
|
+
// shape (`selection_policy` / `current` / `reviews`), but the live forward
|
|
16
|
+
// data is overwhelmingly FLAT — `gate` / `weighted_score` / `scored_at` /
|
|
17
|
+
// `scored_by` / `scores` / `flags` directly on the entry. Reading only the
|
|
18
|
+
// template shape would catalogue 6 entries out of 204. Both are legitimate:
|
|
19
|
+
// the templates were modernised, the data was not migrated.
|
|
20
|
+
// 2. THE CONTAINER KEY IDENTIFIES NOTHING. It drifts between template and
|
|
21
|
+
// estate in 4 of 10 files (`diagrams` vs `c4_diagram_scores`, `capabilities`
|
|
22
|
+
// vs `feature_grounding_scores`, ...) and is non-unique method-side
|
|
23
|
+
// (`domains` names BOTH domain-architecture and intent-quality;
|
|
24
|
+
// `capabilities` names BOTH feature-grounding and traceability-manifest).
|
|
25
|
+
// So `rubric_id` comes from the file's `registry_type` when declared, else
|
|
26
|
+
// the filename stem minus `-scores`. Never the key.
|
|
27
|
+
// 3. `corpus_ref` IS NEW AUTHORING. M-ADR 0205 §4.5 names it and the coverage
|
|
28
|
+
// instrument's staleness rule depends on it, but nothing authors it in
|
|
29
|
+
// either estate today. Rows therefore carry `staleness_basis` saying which
|
|
30
|
+
// anchor they can actually support, so no consumer can state a staleness
|
|
31
|
+
// verdict without its basis.
|
|
32
|
+
//
|
|
33
|
+
// LOUDNESS CONTRACT. Nothing here drops silently — that is the whole point of
|
|
34
|
+
// the class. An unparseable registry, an unknown rubric_id, an ambiguous
|
|
35
|
+
// container, a registry_type disagreeing with its filename, and an
|
|
36
|
+
// out-of-vocabulary gate value each produce a finding naming the file, the
|
|
37
|
+
// entry and the remedy. The one thing that is NOT a finding is a registry with
|
|
38
|
+
// an empty container: the method repo ships fifteen of those deliberately, and
|
|
39
|
+
// their emptiness is the signal the B2 coverage predicate reads, not an error
|
|
40
|
+
// here.
|
|
41
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
42
|
+
import { basename, join } from "node:path";
|
|
43
|
+
import { parse as parseYaml } from "yaml";
|
|
44
|
+
const REGISTRY_DIR_REL = "orchestration/gate-registry";
|
|
45
|
+
/**
|
|
46
|
+
* File-level keys that are registry metadata rather than scored entries.
|
|
47
|
+
* Anything else at the top level is the entry container. The reverse family's
|
|
48
|
+
* `registry_type` / `assessed_at` / `assessed_by` are here too so that family
|
|
49
|
+
* parses with the same code path, even though only the weighted house form is
|
|
50
|
+
* emitted today (schema 3.2.0's `scoring_form` reserves the banded case).
|
|
51
|
+
*/
|
|
52
|
+
const HEADER_KEYS = new Set([
|
|
53
|
+
"schema_version",
|
|
54
|
+
"description",
|
|
55
|
+
"pass_threshold",
|
|
56
|
+
"registry_type",
|
|
57
|
+
"assessed_at",
|
|
58
|
+
"assessed_by",
|
|
59
|
+
]);
|
|
60
|
+
const BANDED_FAMILIES = new Map([
|
|
61
|
+
[
|
|
62
|
+
"reverse-repo-quality",
|
|
63
|
+
{ targetKind: "repository", rubrics: ["reverseability", "repo_doc_quality"], pathField: "doc_path" },
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
"reverse-product-quality",
|
|
67
|
+
{ targetKind: "product", rubrics: ["product_synthesis_quality"], pathField: "product_path" },
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
"reverse-platform-evidence-quality",
|
|
71
|
+
{ targetKind: "platform_evidence", rubrics: ["reverse_platform_evidence_quality"], pathField: "evidence_path" },
|
|
72
|
+
],
|
|
73
|
+
]);
|
|
74
|
+
/**
|
|
75
|
+
* `domain-artifact` is a THIRD shape and does not fit either map: it wraps a
|
|
76
|
+
* banded score (`current.artifact_quality` = {total, band}) in the multi-review
|
|
77
|
+
* envelope, and names its rubric per-unit in a `rubric:` field pointing at a
|
|
78
|
+
* rubric FILE. Six distinct rubrics across one estate's 66 units, so the
|
|
79
|
+
* registry_type cannot supply rubric_id and the sub-block list cannot either.
|
|
80
|
+
*/
|
|
81
|
+
const DOMAIN_ARTIFACT_TYPE = "reverse-domain-artifact-quality";
|
|
82
|
+
const TARGET_KIND_BY_RUBRIC = new Map([
|
|
83
|
+
["adr-quality", "adr"],
|
|
84
|
+
["contract-quality", "contract"],
|
|
85
|
+
["c4-diagram", "c4_diagram"],
|
|
86
|
+
["domain-architecture", "domain"],
|
|
87
|
+
["intent-quality", "domain"],
|
|
88
|
+
["feature-grounding", "capability"],
|
|
89
|
+
["traceability-manifest", "capability"],
|
|
90
|
+
["glossary", "glossary"],
|
|
91
|
+
["persona", "persona"],
|
|
92
|
+
["product-definition", "product"],
|
|
93
|
+
["screen-spec", "screen"],
|
|
94
|
+
["strategic-doc", "strategic_doc"],
|
|
95
|
+
["user-story", "user_story"],
|
|
96
|
+
["work-order", "work_order"],
|
|
97
|
+
["integration-sdd-quality", "integration"],
|
|
98
|
+
]);
|
|
99
|
+
/** schema 3.2.0 `quality_gate`. Deliberately UNBOUND — see that enum's note. */
|
|
100
|
+
// pass-with-waiver adopted into the method vocabulary 2026-08-07 (ruled; the
|
|
101
|
+
// domain-architecture rubric's waiver machinery generalised). A review
|
|
102
|
+
// carrying it MUST record a waiver block — a waiver that is not recorded is
|
|
103
|
+
// a fail being ignored, so its absence is a convention finding.
|
|
104
|
+
const GATE_VALUES = new Set(["pass", "pass-with-waiver", "conditional", "fail", "unscored"]);
|
|
105
|
+
/** §3i: rubric families whose targets live in a catalogue with a path column. */
|
|
106
|
+
const RUBRIC_PATH_FAMILY = {
|
|
107
|
+
"adr-quality": "adrs",
|
|
108
|
+
"contract-quality": "contracts",
|
|
109
|
+
"intent-quality": "capabilities",
|
|
110
|
+
};
|
|
111
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
112
|
+
const str = (v) => typeof v === "string" ? v : typeof v === "number" ? String(v) : null;
|
|
113
|
+
const num = (v) => {
|
|
114
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
115
|
+
return v;
|
|
116
|
+
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) {
|
|
117
|
+
return Number(v);
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Count raised flags across the two authored dialects:
|
|
123
|
+
* `flags: { over_scope_risk: true, ... }` -> count the true ones
|
|
124
|
+
* `flags_raised: [name, ...]` -> count the list
|
|
125
|
+
* A scalar `flags_raised: 0` also occurs in the wild (one estate authors it),
|
|
126
|
+
* and reads as zero rather than as a parse defect.
|
|
127
|
+
*/
|
|
128
|
+
function countFlags(entry, current) {
|
|
129
|
+
const raised = current["flags_raised"] ?? entry["flags_raised"];
|
|
130
|
+
if (Array.isArray(raised))
|
|
131
|
+
return raised.length;
|
|
132
|
+
if (typeof raised === "number")
|
|
133
|
+
return raised;
|
|
134
|
+
const flags = current["flags"] ?? entry["flags"];
|
|
135
|
+
if (isRecord(flags))
|
|
136
|
+
return Object.values(flags).filter((v) => v === true).length;
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Hard-gate state across the two dialects:
|
|
141
|
+
* `hard_gates: { triggered: bool, reasons: [] }`
|
|
142
|
+
* `hard_gates_triggered: [reasons]` (empty list = not triggered)
|
|
143
|
+
*/
|
|
144
|
+
function hardGateTriggered(entry, current) {
|
|
145
|
+
const list = current["hard_gates_triggered"] ?? entry["hard_gates_triggered"];
|
|
146
|
+
if (Array.isArray(list))
|
|
147
|
+
return list.length > 0;
|
|
148
|
+
const hg = current["hard_gates"] ?? entry["hard_gates"];
|
|
149
|
+
if (isRecord(hg))
|
|
150
|
+
return hg["triggered"] === true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
function requiredActionsCount(entry, current) {
|
|
154
|
+
const explicit = num(current["required_actions_count"] ?? entry["required_actions_count"]);
|
|
155
|
+
if (explicit !== null)
|
|
156
|
+
return explicit;
|
|
157
|
+
const list = current["required_actions"] ?? entry["required_actions"];
|
|
158
|
+
return Array.isArray(list) ? list.length : 0;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Scorer provenance across three dialects: the nested `scorer:` block, the flat
|
|
162
|
+
* `scored_by:` free-text field, and the reverse family's file-level
|
|
163
|
+
* `assessed_by:`. A flat-dialect row therefore lands its whole provenance in
|
|
164
|
+
* `scorer_model` as free text — expected, and documented in the schema, so it
|
|
165
|
+
* does not read as a parse defect to whoever queries it later.
|
|
166
|
+
*/
|
|
167
|
+
function scorerFields(entry, current, fileAssessedBy) {
|
|
168
|
+
const scorer = current["scorer"] ?? entry["scorer"];
|
|
169
|
+
if (isRecord(scorer)) {
|
|
170
|
+
return {
|
|
171
|
+
scorer_provider: str(scorer["provider"]),
|
|
172
|
+
scorer_tool: str(scorer["tool"]),
|
|
173
|
+
scorer_model: str(scorer["model"]),
|
|
174
|
+
scorer_model_family: str(scorer["model_family"]),
|
|
175
|
+
scorer_prompt_version: str(scorer["prompt_version"]),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const flat = str(current["scored_by"] ?? entry["scored_by"]) ?? fileAssessedBy;
|
|
179
|
+
return {
|
|
180
|
+
scorer_provider: null,
|
|
181
|
+
scorer_tool: null,
|
|
182
|
+
scorer_model: flat,
|
|
183
|
+
scorer_model_family: null,
|
|
184
|
+
scorer_prompt_version: null,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** Slug a target ref into a PK-safe token without collapsing distinct refs. */
|
|
188
|
+
const slugToken = (s) => s
|
|
189
|
+
.trim()
|
|
190
|
+
.replace(/[^A-Za-z0-9_.-]+/g, "-")
|
|
191
|
+
.replace(/^-+|-+$/g, "")
|
|
192
|
+
.toLowerCase();
|
|
193
|
+
/**
|
|
194
|
+
* The effective review for a nested entry, or null when there is nothing to
|
|
195
|
+
* select. Resolution order, deliberately explicit rather than "latest wins":
|
|
196
|
+
*
|
|
197
|
+
* 1. `selection_policy.selected_review_run_id` matched against
|
|
198
|
+
* `reviews[].review_run_id` — the registry SAYING which review is
|
|
199
|
+
* effective is authoritative, whatever the dates say.
|
|
200
|
+
* 2. `current.review_run_id` matched the same way — the summary naming its
|
|
201
|
+
* own source.
|
|
202
|
+
* 3. Exactly one review and no id anywhere: it is unambiguous, so use it.
|
|
203
|
+
*
|
|
204
|
+
* Several reviews with no resolvable id is NOT guessed at — the caller reports
|
|
205
|
+
* it. Picking the newest would silently choose a verdict the registry never
|
|
206
|
+
* nominated, and a scores registry's whole point is that the effective record
|
|
207
|
+
* is declared rather than inferred.
|
|
208
|
+
*/
|
|
209
|
+
function selectReview(entry, current) {
|
|
210
|
+
const reviews = entry["reviews"];
|
|
211
|
+
if (!Array.isArray(reviews))
|
|
212
|
+
return null;
|
|
213
|
+
const records = reviews.filter(isRecord);
|
|
214
|
+
if (records.length === 0)
|
|
215
|
+
return null;
|
|
216
|
+
const policy = entry["selection_policy"];
|
|
217
|
+
const wanted = (isRecord(policy) ? str(policy["selected_review_run_id"]) : null) ??
|
|
218
|
+
str(current["review_run_id"]);
|
|
219
|
+
if (wanted !== null) {
|
|
220
|
+
const hit = records.find((r) => str(r["review_run_id"]) === wanted);
|
|
221
|
+
if (hit)
|
|
222
|
+
return hit;
|
|
223
|
+
}
|
|
224
|
+
return records.length === 1 ? records[0] : null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Fields present on `current` win; anything absent there falls back to the
|
|
228
|
+
* selected review. That direction matters: `current` is the registry's own
|
|
229
|
+
* statement of the effective headline, so a gate or weighted_score recorded
|
|
230
|
+
* there overrides the review's copy if they ever disagree — while the detail
|
|
231
|
+
* `current` does not carry comes from the review it points at.
|
|
232
|
+
*/
|
|
233
|
+
function mergePreferringCurrent(current, review) {
|
|
234
|
+
const out = { ...review };
|
|
235
|
+
for (const [k, v] of Object.entries(current)) {
|
|
236
|
+
if (v !== undefined && v !== null)
|
|
237
|
+
out[k] = v;
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
/** Rows for one banded sub-block, or null when the unit does not carry it. */
|
|
242
|
+
function bandedRows(block, ctx) {
|
|
243
|
+
if (!isRecord(block))
|
|
244
|
+
return null;
|
|
245
|
+
const id = `${ctx.rubricId}__${ctx.targetKind}__${slugToken(ctx.targetRef)}`;
|
|
246
|
+
const stalenessBasis = ctx.corpusRef !== null ? "corpus_ref" : ctx.targetPath !== null ? "scored_at" : "none";
|
|
247
|
+
const row = {
|
|
248
|
+
id,
|
|
249
|
+
rubric_id: ctx.rubricId,
|
|
250
|
+
scoring_form: "banded",
|
|
251
|
+
target_kind: ctx.targetKind,
|
|
252
|
+
target_ref: ctx.targetRef,
|
|
253
|
+
target_path: ctx.targetPath,
|
|
254
|
+
gate: null,
|
|
255
|
+
weighted_score: null,
|
|
256
|
+
score_denominator: null,
|
|
257
|
+
band: str(block["band"]),
|
|
258
|
+
band_total: num(block["total"]),
|
|
259
|
+
scored_at: ctx.scoredAt,
|
|
260
|
+
corpus_ref: ctx.corpusRef,
|
|
261
|
+
staleness_basis: stalenessBasis,
|
|
262
|
+
review_run_id: null,
|
|
263
|
+
selection_method: null,
|
|
264
|
+
scorer_provider: null,
|
|
265
|
+
scorer_tool: null,
|
|
266
|
+
scorer_model: ctx.assessedBy,
|
|
267
|
+
scorer_model_family: null,
|
|
268
|
+
scorer_prompt_version: null,
|
|
269
|
+
flag_count: 0,
|
|
270
|
+
hard_gate_triggered: false,
|
|
271
|
+
required_actions_count: 0,
|
|
272
|
+
sdd_id: ctx.sddId,
|
|
273
|
+
source_sdd_id: null,
|
|
274
|
+
};
|
|
275
|
+
const dims = [];
|
|
276
|
+
const d = block["dimensions"];
|
|
277
|
+
if (isRecord(d)) {
|
|
278
|
+
for (const [k, v] of Object.entries(d)) {
|
|
279
|
+
dims.push({
|
|
280
|
+
id: `${id}__${slugToken(k)}`,
|
|
281
|
+
quality_score_id: id,
|
|
282
|
+
rubric_id: ctx.rubricId,
|
|
283
|
+
target_kind: ctx.targetKind,
|
|
284
|
+
target_ref: ctx.targetRef,
|
|
285
|
+
dimension_key: k,
|
|
286
|
+
dimension_score: num(v),
|
|
287
|
+
dimension_weight: null,
|
|
288
|
+
source_sdd_id: null,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { row, dims };
|
|
293
|
+
}
|
|
294
|
+
export async function buildQualityScores(inputs) {
|
|
295
|
+
let derivedPathCount = 0;
|
|
296
|
+
const { sddPath, findings, sddId } = inputs;
|
|
297
|
+
const dir = join(sddPath, REGISTRY_DIR_REL);
|
|
298
|
+
let files;
|
|
299
|
+
try {
|
|
300
|
+
files = (await readdir(dir))
|
|
301
|
+
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
|
|
302
|
+
.sort();
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// No gate-registry directory. Headers-only, and NOT a finding: the surface
|
|
306
|
+
// is optional per profile, and absence of the whole surface is not an
|
|
307
|
+
// authoring gap in it (the repo-config predicate's n/a rule, same reasoning).
|
|
308
|
+
return { qualityScores: [], qualityScoreDimensions: [] };
|
|
309
|
+
}
|
|
310
|
+
const qualityScores = [];
|
|
311
|
+
const qualityScoreDimensions = [];
|
|
312
|
+
for (const file of files) {
|
|
313
|
+
const rel = `${REGISTRY_DIR_REL}/${file}`;
|
|
314
|
+
const raw = await readFile(join(dir, file), "utf8");
|
|
315
|
+
let doc;
|
|
316
|
+
try {
|
|
317
|
+
doc = parseYaml(raw);
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
// Measured: 3 of one estate's 10 registries do not parse (two mixing
|
|
321
|
+
// mapping and sequence entries under one container, one with a duplicate
|
|
322
|
+
// key whose two blocks hold CONTRADICTORY verdicts for the same target).
|
|
323
|
+
// A parser that tolerates duplicate keys reports the flattering figure
|
|
324
|
+
// and hides it; this one refuses, and says so.
|
|
325
|
+
// WARN, not error, and the choice is deliberate. Score registries are
|
|
326
|
+
// scoring-run output, not a method-mandated authoring surface, and the
|
|
327
|
+
// same reasoning that leaves `quality_gate` unbound applies here: this
|
|
328
|
+
// class must not hard-fail an estate's whole catalogue build over data
|
|
329
|
+
// that predates it (measured: one estate carries three such files). The
|
|
330
|
+
// loudness comes from the findings baseline — a new unbaselined finding
|
|
331
|
+
// fails the gate — which is how this method makes things loud without
|
|
332
|
+
// making builds brittle.
|
|
333
|
+
findings.add("divergence", `${rel} is not parseable YAML (${String(e.message).split("\n")[0]}) — every score it holds is invisible to the catalogue; repair the file (a duplicate key silently discards one of two judgments, and a mapping/sequence mix is invalid in every parser)`);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (!isRecord(doc))
|
|
337
|
+
continue;
|
|
338
|
+
// --- rubric_id: registry_type, else filename. Never the container key. ---
|
|
339
|
+
const stem = basename(file).replace(/\.(yaml|yml)$/, "");
|
|
340
|
+
const fromFilename = stem.replace(/-scores$/, "");
|
|
341
|
+
const declared = str(doc["registry_type"]);
|
|
342
|
+
const rubricId = declared ?? fromFilename;
|
|
343
|
+
if (declared !== null && declared !== fromFilename) {
|
|
344
|
+
findings.add("convention", `${rel} declares registry_type '${declared}' but its filename says '${fromFilename}' — rubric_id uses the declared value; rename the file or correct registry_type so the two agree`);
|
|
345
|
+
}
|
|
346
|
+
const fileAssessedBy = str(doc["assessed_by"]);
|
|
347
|
+
// --- the banded reverse family (schema 3.3.0) ---
|
|
348
|
+
const banded = BANDED_FAMILIES.get(rubricId);
|
|
349
|
+
if (banded !== undefined || rubricId === DOMAIN_ARTIFACT_TYPE) {
|
|
350
|
+
const units = doc["units"];
|
|
351
|
+
if (!isRecord(units)) {
|
|
352
|
+
findings.add("convention", `${rel} declares registry_type '${rubricId}' but has no \`units:\` mapping — the banded family keys its entries there; no scores read from this file`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const fileScoredAt = str(doc["assessed_at"]);
|
|
356
|
+
for (const [unitKey, unitVal] of Object.entries(units)) {
|
|
357
|
+
if (!isRecord(unitVal))
|
|
358
|
+
continue;
|
|
359
|
+
const targetRef = str(unitVal["unit_id"]) ?? unitKey;
|
|
360
|
+
// The reverse family records the exported snapshot it read — the one
|
|
361
|
+
// place a corpus anchor is already authored anywhere in either estate.
|
|
362
|
+
const snap = unitVal["snapshot"];
|
|
363
|
+
const corpusRef = isRecord(snap) ? str(snap["source_revision"]) : null;
|
|
364
|
+
if (banded !== undefined) {
|
|
365
|
+
const targetPath = banded.pathField ? str(unitVal[banded.pathField]) : null;
|
|
366
|
+
for (const sub of banded.rubrics) {
|
|
367
|
+
const built = bandedRows(unitVal[sub], {
|
|
368
|
+
rubricId: `${rubricId}.${sub}`,
|
|
369
|
+
targetKind: banded.targetKind,
|
|
370
|
+
targetRef,
|
|
371
|
+
targetPath,
|
|
372
|
+
scoredAt: fileScoredAt,
|
|
373
|
+
corpusRef,
|
|
374
|
+
assessedBy: fileAssessedBy,
|
|
375
|
+
sddId,
|
|
376
|
+
});
|
|
377
|
+
if (built) {
|
|
378
|
+
qualityScores.push(built.row);
|
|
379
|
+
qualityScoreDimensions.push(...built.dims);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
// domain-artifact: rubric named per unit, banded score inside `current`.
|
|
385
|
+
const current = isRecord(unitVal["current"]) ? unitVal["current"] : {};
|
|
386
|
+
const rubricPath = str(unitVal["rubric"]);
|
|
387
|
+
if (rubricPath === null) {
|
|
388
|
+
findings.add("coverage", `${rel}: ${unitKey} names no \`rubric:\` — this family identifies its rubric per unit, so the score cannot be attributed; add the rubric path`);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const stem = rubricPath.split("/").pop().replace(/\.md$/, "");
|
|
392
|
+
const built = bandedRows(current["artifact_quality"], {
|
|
393
|
+
rubricId: stem,
|
|
394
|
+
targetKind: str(unitVal["artifact_type"]) ?? "domain_artifact",
|
|
395
|
+
targetRef,
|
|
396
|
+
targetPath: str(unitVal["doc_path"]),
|
|
397
|
+
scoredAt: str(current["scored_at"]) ?? fileScoredAt,
|
|
398
|
+
corpusRef,
|
|
399
|
+
assessedBy: fileAssessedBy,
|
|
400
|
+
sddId,
|
|
401
|
+
});
|
|
402
|
+
if (built) {
|
|
403
|
+
qualityScores.push(built.row);
|
|
404
|
+
qualityScoreDimensions.push(...built.dims);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const targetKind = TARGET_KIND_BY_RUBRIC.get(rubricId);
|
|
411
|
+
if (targetKind === undefined) {
|
|
412
|
+
findings.add("coverage", `${rel} uses rubric '${rubricId}', which has no target-kind mapping — its scores are NOT catalogued; add the rubric to the quality-scores builder's TARGET_KIND_BY_RUBRIC map (a new rubric ships its registry, rubric, skill trio and seed rows; this is the fifth surface)`);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
// --- the entry container: the single non-header mapping OF ENTRIES ---
|
|
416
|
+
//
|
|
417
|
+
// Detected STRUCTURALLY, not positionally. The first version counted
|
|
418
|
+
// non-header mapping keys and refused any file with more than one — which
|
|
419
|
+
// cost a whole registry whenever an author put a `rollup:` summary block
|
|
420
|
+
// beside the entries. Measured: two registries in one estate, 11 authored
|
|
421
|
+
// units and 75 dimension rows silently uncatalogued, and the summary is
|
|
422
|
+
// trivially distinguishable — its values are scalars (scored_at, total,
|
|
423
|
+
// pass/conditional/fail, note) while an entry container's values are
|
|
424
|
+
// themselves mappings.
|
|
425
|
+
//
|
|
426
|
+
// The ambiguity guard survives for the case it was actually written for:
|
|
427
|
+
// if TWO mappings both hold entry-shaped values, that is genuinely
|
|
428
|
+
// ambiguous and is still refused rather than guessed.
|
|
429
|
+
const containers = Object.entries(doc).filter(([k, v]) => !HEADER_KEYS.has(k) &&
|
|
430
|
+
isRecord(v) &&
|
|
431
|
+
Object.values(v).some((x) => isRecord(x)));
|
|
432
|
+
if (containers.length === 0)
|
|
433
|
+
continue; // headers-only registry: deliberate, silent
|
|
434
|
+
if (containers.length > 1) {
|
|
435
|
+
findings.add("convention", `${rel} has ${containers.length} mappings holding entry-shaped values (${containers.map(([k]) => k).join(", ")}) — a registry declares exactly one entry container; no scores read from this file. A scalar-valued summary block beside the entries is fine and is ignored; two containers of entries are not.`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const container = containers[0]?.[1];
|
|
439
|
+
if (!isRecord(container))
|
|
440
|
+
continue;
|
|
441
|
+
checkNaRationale(rel, container, findings);
|
|
442
|
+
for (const [entryKey, entryVal] of Object.entries(container)) {
|
|
443
|
+
if (!isRecord(entryVal))
|
|
444
|
+
continue;
|
|
445
|
+
// --- dialect detection: nested (templates) vs flat (live estate data) ---
|
|
446
|
+
const nested = isRecord(entryVal["current"]);
|
|
447
|
+
const currentBlock = nested
|
|
448
|
+
? entryVal["current"]
|
|
449
|
+
: entryVal;
|
|
450
|
+
// `current` is a SUMMARY, not necessarily the whole record. In the
|
|
451
|
+
// multi-review shape the method's own templates specify, the detail —
|
|
452
|
+
// scores, flags, hard_gates, required_actions — lives ONLY in the
|
|
453
|
+
// selected `reviews[]` entry, and `current` carries just the headline
|
|
454
|
+
// (run id, date, gate, weighted_score, scorer). Reading `current` alone
|
|
455
|
+
// therefore drops the detail SILENTLY, which is exactly what it did:
|
|
456
|
+
// measured on a real estate, six user-story entries lost 36 dimension
|
|
457
|
+
// rows and 13 required-action counts, and their flag counts read 0 only
|
|
458
|
+
// because those six happened to raise none — luck, not correctness.
|
|
459
|
+
const selected = selectReview(entryVal, currentBlock);
|
|
460
|
+
const current = selected
|
|
461
|
+
? mergePreferringCurrent(currentBlock, selected)
|
|
462
|
+
: currentBlock;
|
|
463
|
+
if (nested && selected === null && !isRecord(currentBlock["scores"])) {
|
|
464
|
+
const reviews = entryVal["reviews"];
|
|
465
|
+
if (Array.isArray(reviews) && reviews.length > 0) {
|
|
466
|
+
findings.add("coverage", `${rel}: ${entryKey} has ${reviews.length} review(s) but none can be selected as effective (selection_policy.selected_review_run_id matches no review_run_id, and current.review_run_id does not either) — its dimension scores are NOT catalogued; set selected_review_run_id to one of the recorded runs`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Flat headline keys beside a `current:` block are a compatibility
|
|
470
|
+
// shim, and a shim that DISAGREES with the effective record is the
|
|
471
|
+
// silent-failure shape found live (ledger §3am): an ingestion updated
|
|
472
|
+
// the flat score and left `current:` stale, and the catalogue published
|
|
473
|
+
// the stale value with the right one inches away in the same entry —
|
|
474
|
+
// no complaint anywhere. The disagreement is loud now. scored_at is
|
|
475
|
+
// deliberately not compared (YAML date-typing would false-positive).
|
|
476
|
+
if (nested) {
|
|
477
|
+
const disagreements = [];
|
|
478
|
+
for (const key of ["gate", "weighted_score", "corpus_ref"]) {
|
|
479
|
+
const flat = entryVal[key];
|
|
480
|
+
const eff = current[key];
|
|
481
|
+
if (flat !== undefined && flat !== null && String(flat) !== String(eff ?? "")) {
|
|
482
|
+
disagreements.push(`${key} (flat ${String(flat)} vs effective ${String(eff ?? "(empty)")})`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (disagreements.length > 0) {
|
|
486
|
+
findings.add("convention", `${rel}: ${entryKey} carries flat headline keys that disagree with its effective current/selected review: ${disagreements.join("; ")} — \`current:\` is authoritative and the flat keys are a compatibility shim (scoring-registry guide); update current:, and keep the flat keys agreeing or remove them`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const targetRef = str(entryVal["unit_id"]) ?? entryKey;
|
|
490
|
+
let targetPath = str(entryVal["file_path"]) ?? str(current["file_path"]);
|
|
491
|
+
if (targetPath === null && inputs.cataloguePathIndex !== undefined) {
|
|
492
|
+
const family = RUBRIC_PATH_FAMILY[rubricId];
|
|
493
|
+
const index = family === undefined ? undefined : inputs.cataloguePathIndex.get(family);
|
|
494
|
+
if (index !== undefined) {
|
|
495
|
+
const key = family === "adrs" ? targetRef.replace(/^[A-Z]+-ADR-/, "ADR-") : targetRef;
|
|
496
|
+
const derived = index.get(key);
|
|
497
|
+
if (derived !== undefined && derived !== "") {
|
|
498
|
+
targetPath = derived;
|
|
499
|
+
derivedPathCount += 1;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// --- gate: unbound vocabulary, convention finding + empty cell ---
|
|
504
|
+
const rawGate = str(current["gate"]);
|
|
505
|
+
let gate = null;
|
|
506
|
+
if (rawGate !== null && rawGate.trim() !== "") {
|
|
507
|
+
if (GATE_VALUES.has(rawGate)) {
|
|
508
|
+
gate = rawGate;
|
|
509
|
+
if (rawGate === "pass-with-waiver") {
|
|
510
|
+
const waiver = entryVal["waiver"] ?? current["waiver"];
|
|
511
|
+
if (!isRecord(waiver) || str(waiver["granted_by"]) === null || str(waiver["expires"]) === null) {
|
|
512
|
+
findings.add("convention", `${rel}: ${entryKey} is pass-with-waiver but records no waiver block (granted_by + expires required) — an unrecorded waiver is a fail being ignored; record the waiver or re-score`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
findings.add("convention", `${rel}: ${entryKey} has gate '${rawGate}', outside the quality_gate vocabulary (${[...GATE_VALUES].join(" | ")}) — the cell is left EMPTY rather than emitting a value no consumer can interpret; use an in-vocabulary value, or raise the extension as a method decision if the concept is real`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const corpusRef = str(current["corpus_ref"]) ?? str(entryVal["corpus_ref"]);
|
|
521
|
+
const stalenessBasis = corpusRef !== null ? "corpus_ref" : targetPath !== null ? "scored_at" : "none";
|
|
522
|
+
const selectionPolicy = entryVal["selection_policy"];
|
|
523
|
+
const selectionMethod = isRecord(selectionPolicy)
|
|
524
|
+
? str(selectionPolicy["method"])
|
|
525
|
+
: null;
|
|
526
|
+
const id = `${rubricId}__${targetKind}__${slugToken(targetRef)}`;
|
|
527
|
+
qualityScores.push({
|
|
528
|
+
id,
|
|
529
|
+
rubric_id: rubricId,
|
|
530
|
+
scoring_form: "weighted",
|
|
531
|
+
target_kind: targetKind,
|
|
532
|
+
target_ref: targetRef,
|
|
533
|
+
target_path: targetPath,
|
|
534
|
+
gate,
|
|
535
|
+
weighted_score: num(current["weighted_score"]),
|
|
536
|
+
score_denominator: num(entryVal["score_denominator"] ?? current["score_denominator"]),
|
|
537
|
+
// Empty on a weighted row by design — the two scales never share a
|
|
538
|
+
// column, so an average over weighted_score is correct by construction.
|
|
539
|
+
band: null,
|
|
540
|
+
band_total: null,
|
|
541
|
+
scored_at: str(current["scored_at"]) ?? str(entryVal["scored_at"]),
|
|
542
|
+
corpus_ref: corpusRef,
|
|
543
|
+
staleness_basis: stalenessBasis,
|
|
544
|
+
review_run_id: str(current["review_run_id"]),
|
|
545
|
+
selection_method: selectionMethod,
|
|
546
|
+
...scorerFields(entryVal, current, fileAssessedBy),
|
|
547
|
+
flag_count: countFlags(entryVal, current),
|
|
548
|
+
hard_gate_triggered: hardGateTriggered(entryVal, current),
|
|
549
|
+
required_actions_count: requiredActionsCount(entryVal, current),
|
|
550
|
+
sdd_id: sddId,
|
|
551
|
+
source_sdd_id: null,
|
|
552
|
+
});
|
|
553
|
+
// --- dimensions: the rubric's own keys, verbatim ---
|
|
554
|
+
const scores = current["scores"];
|
|
555
|
+
if (isRecord(scores)) {
|
|
556
|
+
for (const [dimKey, dimVal] of Object.entries(scores)) {
|
|
557
|
+
qualityScoreDimensions.push({
|
|
558
|
+
id: `${id}__${slugToken(dimKey)}`,
|
|
559
|
+
quality_score_id: id,
|
|
560
|
+
rubric_id: rubricId,
|
|
561
|
+
target_kind: targetKind,
|
|
562
|
+
target_ref: targetRef,
|
|
563
|
+
dimension_key: dimKey,
|
|
564
|
+
dimension_score: num(dimVal),
|
|
565
|
+
dimension_weight: null,
|
|
566
|
+
source_sdd_id: null,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Total order, per M-ADR 0205 §4.2 — the committed CSV must be byte-stable.
|
|
573
|
+
qualityScores.sort((a, b) => String(a["id"]).localeCompare(String(b["id"])));
|
|
574
|
+
qualityScoreDimensions.sort((a, b) => String(a["id"]).localeCompare(String(b["id"])));
|
|
575
|
+
// Duplicate ids mean two entries scored the same target under one rubric.
|
|
576
|
+
// BOTH rows are emitted rather than one being dropped: dropping would pick a
|
|
577
|
+
// winner between two possibly-contradictory verdicts, which is precisely how
|
|
578
|
+
// that defect stayed invisible in an estate for a month. The builder
|
|
579
|
+
// diagnoses; the downstream pk-duplicate check owns fatality, which is where
|
|
580
|
+
// PK integrity belongs.
|
|
581
|
+
const seen = new Set();
|
|
582
|
+
for (const row of qualityScores) {
|
|
583
|
+
const id = String(row["id"]);
|
|
584
|
+
if (seen.has(id)) {
|
|
585
|
+
findings.add("divergence", `quality_scores: duplicate id '${id}' — two registry entries score the same target under the same rubric; both rows are emitted (the pk check fails on them) rather than one being silently preferred, because the two verdicts may disagree`);
|
|
586
|
+
}
|
|
587
|
+
seen.add(id);
|
|
588
|
+
}
|
|
589
|
+
// §3i: derivation is RECORDED, not silent — a derived path changes a row's
|
|
590
|
+
// staleness basis, and an unstated basis change is the class this whole
|
|
591
|
+
// family exists to prevent.
|
|
592
|
+
if (derivedPathCount > 0) {
|
|
593
|
+
inputs.findings.add("coverage", `quality_scores: ${derivedPathCount} target path(s) resolved through the catalogue rather than authored in the registry — staleness basis becomes scored_at (coarse); author file_path (or corpus_ref, precise) to upgrade`);
|
|
594
|
+
}
|
|
595
|
+
return { qualityScores, qualityScoreDimensions };
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* `na_rationale` (scoring-registry guide, ruled 2026-10-01): an N/A dimension is ABSENT from
|
|
599
|
+
* `scores` and explained in a `na_rationale` mapping beside it, keyed by the same dimension key.
|
|
600
|
+
* Checked on the effective record (the selected review merged with `current`), report-only:
|
|
601
|
+
* - na_rationale must be a mapping (a single string cannot say WHICH dimension it explains);
|
|
602
|
+
* - a dimension cannot be both scored and N/A.
|
|
603
|
+
* NOT checked here: that every N/A dimension HAS a reason. That needs the rubric's own dimension
|
|
604
|
+
* list. Deriving it from the registry being checked (the union of keys its entries score) was tried
|
|
605
|
+
* and measured wrong twice on one estate: a registry mixing two spellings of one rubric's keys, and
|
|
606
|
+
* entries scored before the rubric gained a dimension. Both are artefacts of a self-supplied
|
|
607
|
+
* denominator, not missing reasons.
|
|
608
|
+
*/
|
|
609
|
+
export function checkNaRationale(rel, container, findings) {
|
|
610
|
+
const effective = [];
|
|
611
|
+
for (const [entryKey, entryVal] of Object.entries(container)) {
|
|
612
|
+
if (!isRecord(entryVal))
|
|
613
|
+
continue;
|
|
614
|
+
const currentBlock = isRecord(entryVal["current"]) ? entryVal["current"] : entryVal;
|
|
615
|
+
const selected = selectReview(entryVal, currentBlock);
|
|
616
|
+
effective.push([entryKey, selected ? mergePreferringCurrent(currentBlock, selected) : currentBlock]);
|
|
617
|
+
}
|
|
618
|
+
for (const [entryKey, rec] of effective) {
|
|
619
|
+
if (!isRecord(rec["scores"]))
|
|
620
|
+
continue;
|
|
621
|
+
const scored = new Set(Object.keys(rec["scores"]));
|
|
622
|
+
const na = rec["na_rationale"];
|
|
623
|
+
if (na !== undefined && !isRecord(na)) {
|
|
624
|
+
findings.add("convention", `${rel}: ${entryKey} na_rationale is a ${Array.isArray(na) ? "list" : typeof na}, not a mapping — key each reason by the dimension it explains (scoring-registry guide, Recording N/A)`, "warn", `na_rationale:${rel}:${entryKey}`);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const explained = new Set(isRecord(na) ? Object.keys(na) : []);
|
|
628
|
+
for (const k of explained) {
|
|
629
|
+
if (scored.has(k)) {
|
|
630
|
+
findings.add("convention", `${rel}: ${entryKey} dimension ${k} is both scored and given an N/A reason — it is one or the other`, "warn", `na_rationale:${rel}:${entryKey}:${k}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
//# sourceMappingURL=quality-scores.js.map
|