@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,69 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
// Emits the `conformance_finding` catalogue (canonical-schema.yaml, schema 2.25.0) — one summary row per
|
|
4
|
+
// check, so estate-wide drift is queryable (aggregate unions every SDD's conformance_finding.csv and
|
|
5
|
+
// stamps source_sdd_id) and trendable (snapshots are run-dated; diff across commits/runs). The verb is
|
|
6
|
+
// the producer (it needs the *code* repo, which catalogue builders don't see); aggregation federates it.
|
|
7
|
+
// Column order is authoritative — matches the canonical-schema conformance_finding definition.
|
|
8
|
+
//
|
|
9
|
+
// ⚠ It said that while emitting 15 of canon's 16 — `platform_sdd_id` was absent — so a
|
|
10
|
+
// conformance run wrote a file one column short of the schema it claimed to match, and the
|
|
11
|
+
// comment's confidence is why nobody re-checked it. Every other per-SDD catalogue
|
|
12
|
+
// (services, repositories, capabilities) emits the column empty and lets aggregation stamp
|
|
13
|
+
// it, so 15 was never the per-SDD shape. Found by an adopter reading the list because a
|
|
14
|
+
// change to it was proposed; verified against canon and against every conformance_finding
|
|
15
|
+
// on disk in two estates, all 16 columns.
|
|
16
|
+
export const FINDING_COLUMNS = [
|
|
17
|
+
"id",
|
|
18
|
+
"check_id",
|
|
19
|
+
"target",
|
|
20
|
+
"implementation_repo",
|
|
21
|
+
"role",
|
|
22
|
+
"lifecycle",
|
|
23
|
+
"declared",
|
|
24
|
+
"observed",
|
|
25
|
+
"matched",
|
|
26
|
+
"conformance_pct",
|
|
27
|
+
"declared_not_observed",
|
|
28
|
+
"observed_not_declared",
|
|
29
|
+
"needs_triage",
|
|
30
|
+
"run_date",
|
|
31
|
+
"source_sdd_id",
|
|
32
|
+
"platform_sdd_id",
|
|
33
|
+
];
|
|
34
|
+
function csvCell(v) {
|
|
35
|
+
const s = String(v);
|
|
36
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
37
|
+
}
|
|
38
|
+
export function findingRows(results, runDate) {
|
|
39
|
+
const lines = [FINDING_COLUMNS.join(",")];
|
|
40
|
+
for (const r of results) {
|
|
41
|
+
const row = {
|
|
42
|
+
id: r.id,
|
|
43
|
+
check_id: r.id,
|
|
44
|
+
target: r.target,
|
|
45
|
+
implementation_repo: r.implementationRepo ?? "",
|
|
46
|
+
role: r.role ?? "",
|
|
47
|
+
lifecycle: r.lifecycle ?? "",
|
|
48
|
+
declared: r.declared,
|
|
49
|
+
observed: r.observed,
|
|
50
|
+
matched: r.matched,
|
|
51
|
+
conformance_pct: r.conformancePct,
|
|
52
|
+
declared_not_observed: r.declaredNotObserved.length,
|
|
53
|
+
observed_not_declared: r.observedNotDeclared.length,
|
|
54
|
+
needs_triage: r.needsTriage,
|
|
55
|
+
run_date: runDate,
|
|
56
|
+
source_sdd_id: "", // populated by the aggregator per ADR 0134
|
|
57
|
+
platform_sdd_id: "", // stamped at organisation tier per M-ADR 0208
|
|
58
|
+
};
|
|
59
|
+
lines.push(FINDING_COLUMNS.map((c) => csvCell(row[c])).join(","));
|
|
60
|
+
}
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
export async function writeFindings(cataloguesDir, results, runDate) {
|
|
64
|
+
await mkdir(cataloguesDir, { recursive: true });
|
|
65
|
+
const path = join(cataloguesDir, "conformance_finding.csv");
|
|
66
|
+
await writeFile(path, findingRows(results, runDate).join("\n") + "\n", "utf-8");
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=finding.js.map
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isAbsolute, resolve } from "node:path";
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
import { deriveChecksFromBinding, hasBinding, } from "./binding.js";
|
|
7
|
+
import { writeFindings } from "./finding.js";
|
|
8
|
+
// ---------------------------------------------------------------- path + extractors
|
|
9
|
+
function resolveSource(targetRepo, source, codeRoot) {
|
|
10
|
+
// `@code/...` resolves against --code-root (the checkout root of implementation member repos —
|
|
11
|
+
// code typically lives in a repo separate from the spec SDD). Absent code-root → unresolved → skip.
|
|
12
|
+
if (source.startsWith("@code/")) {
|
|
13
|
+
return codeRoot ? resolve(codeRoot, source.slice("@code/".length)) : "";
|
|
14
|
+
}
|
|
15
|
+
const expanded = source.startsWith("~") ? source.replace(/^~/, homedir()) : source;
|
|
16
|
+
return isAbsolute(expanded) ? expanded : resolve(targetRepo, expanded);
|
|
17
|
+
}
|
|
18
|
+
const markdownProviderSignals = async (path, spec) => {
|
|
19
|
+
const out = new Set();
|
|
20
|
+
// `section` (ADR 0180 delta-pilot extension) scopes the scan to one heading block and accepts a plain
|
|
21
|
+
// "Field" header, so a check can read ONE entity table inside a multi-table data-model doc (e.g.
|
|
22
|
+
// data-model.mermaid.md "### Collection: feature") without duplicating the field list into a bespoke
|
|
23
|
+
// surface file. With no `section` the behaviour is byte-for-byte the legacy one (whole file, strict
|
|
24
|
+
// header, no `*` stripping) — existing gateway bindings are unaffected.
|
|
25
|
+
const section = spec?.section;
|
|
26
|
+
// One provider variant uses "Provider Signal"; another uses "Provider Field" — both name the vendor surface col.
|
|
27
|
+
// Sectioned reads also accept the data-model docs' plain "Field" first column.
|
|
28
|
+
const headers = spec?.column
|
|
29
|
+
? new Set([spec.column])
|
|
30
|
+
: section
|
|
31
|
+
? new Set(["Provider Signal", "Provider Field", "Field"])
|
|
32
|
+
: new Set(["Provider Signal", "Provider Field"]);
|
|
33
|
+
let inTable = false;
|
|
34
|
+
let inSection = !section; // no filter → the whole file counts as in-section
|
|
35
|
+
for (const raw of (await readFile(path, "utf-8")).split("\n")) {
|
|
36
|
+
const s = raw.trim();
|
|
37
|
+
if (section && s.startsWith("#")) {
|
|
38
|
+
// strip leading #'s and any markdown code/emphasis in the title (`### Collection: ` + "`feature`")
|
|
39
|
+
inSection = s.replace(/^#+\s*/, "").replace(/[`*]/g, "").includes(section);
|
|
40
|
+
inTable = false;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!s.startsWith("|")) {
|
|
44
|
+
inTable = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!inSection)
|
|
48
|
+
continue;
|
|
49
|
+
const cells = s.replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
|
50
|
+
const first = cells[0] ?? "";
|
|
51
|
+
if (first === "" || /^[-: ]+$/.test(first))
|
|
52
|
+
continue;
|
|
53
|
+
if (headers.has(first)) {
|
|
54
|
+
inTable = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (inTable) {
|
|
58
|
+
let sig = first.replace(/`/g, "").trim();
|
|
59
|
+
if (section)
|
|
60
|
+
sig = sig.replace(/\*/g, "").trim(); // strip markdown emphasis around field names
|
|
61
|
+
if (!sig || sig === "—" || sig.startsWith("Computed") || sig.startsWith("MB "))
|
|
62
|
+
continue;
|
|
63
|
+
sig = sig.replace(/\([^)]*\)/g, "").replace(/\[\]/g, "").trim(); // drop inline "(...)" notes + array markers
|
|
64
|
+
// Range shorthand: `datacoldTemp1..6` / `setpointTemp1..3` -> one declared name per index.
|
|
65
|
+
const range = sig.match(/^(.*?)(\d+)\.\.(\d+)$/);
|
|
66
|
+
if (range?.[1] !== undefined) {
|
|
67
|
+
const base = range[1];
|
|
68
|
+
for (let i = Number(range[2]); i <= Number(range[3]); i++)
|
|
69
|
+
out.add(`${base}${i}`);
|
|
70
|
+
}
|
|
71
|
+
else if (sig) {
|
|
72
|
+
out.add(sig);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
const javaStringConst = async (path) => {
|
|
79
|
+
const out = new Set();
|
|
80
|
+
const re = /public\s+static\s+final\s+String\s+\w+\s*=\s*"([^"]+)"/g;
|
|
81
|
+
const text = await readFile(path, "utf-8");
|
|
82
|
+
for (const m of text.matchAll(re))
|
|
83
|
+
if (m[1])
|
|
84
|
+
out.add(m[1]);
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
// Vendor field/signal names expressed as Java DTO fields (`private Double latitude;`) or record
|
|
88
|
+
// components (`Long hrTotalVehicleDistance,`). The per-field DTO shape (e.g. a signal class) some
|
|
89
|
+
// providers use, vs another provider's string-constant class. Targeted at pure data classes/records (DTO files).
|
|
90
|
+
const javaFieldNames = async (path) => {
|
|
91
|
+
const out = new Set();
|
|
92
|
+
const text = await readFile(path, "utf-8");
|
|
93
|
+
// class fields: [modifier] <Type> <name>; or [modifier] <Type> <name> = <initialiser>;
|
|
94
|
+
// (an initialised field — `List<T> x = new ArrayList<>()` — was invisible, so a check could
|
|
95
|
+
// not tell "not read" from "not seen").
|
|
96
|
+
for (const m of text.matchAll(/(?:private|protected|public)\s+[\w<>,.?\[\]\s]+?\s+(\w+)\s*[;=]/g)) {
|
|
97
|
+
if (m[1])
|
|
98
|
+
out.add(m[1]);
|
|
99
|
+
}
|
|
100
|
+
// Lombok: a class annotated @Data/@Getter/@Value generates accessors for package-private fields, which
|
|
101
|
+
// carry no access modifier. Accept `<Type> <name>;` / `<Type> <name> = …;` at exactly one indentation level
|
|
102
|
+
// (4 spaces or a tab), where fields sit; a local variable inside a method is deeper and is not read.
|
|
103
|
+
if (/@(?:Data|Getter|Value)\b/.test(text)) {
|
|
104
|
+
for (const m of text.matchAll(/^(?: {4}|\t)(?![ \t])(?:final\s+)?(?!return\b|throw\b|new\b)[A-Z][\w<>,.?\[\]\s]*?\s+([a-z]\w*)\s*[;=]/gm)) {
|
|
105
|
+
if (m[1])
|
|
106
|
+
out.add(m[1]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// record components: <Type> <name>, | <Type> <name>) (Type uppercase-initial, name lowercase-initial)
|
|
110
|
+
for (const m of text.matchAll(/\b[A-Z][\w<>.]*\s+([a-z]\w*)\s*[,)]/g)) {
|
|
111
|
+
if (m[1])
|
|
112
|
+
out.add(m[1]);
|
|
113
|
+
}
|
|
114
|
+
// Wire aliases: @JsonProperty("long") / @SerializedName("...") name the on-the-wire field, which is
|
|
115
|
+
// what the provider-surface grounding declares — emit it alongside the Java identifier.
|
|
116
|
+
for (const m of text.matchAll(/@(?:JsonProperty|SerializedName|JsonAlias)\s*\(\s*"([^"]+)"/g)) {
|
|
117
|
+
if (m[1])
|
|
118
|
+
out.add(m[1]);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
};
|
|
122
|
+
const yamlProducedEvents = async (path) => {
|
|
123
|
+
const out = new Set();
|
|
124
|
+
const doc = parseYaml(await readFile(path, "utf-8"));
|
|
125
|
+
for (const p of doc?.events?.produces ?? []) {
|
|
126
|
+
const schema = p?.schema;
|
|
127
|
+
if (typeof schema === "string") {
|
|
128
|
+
const base = schema.split("/").pop() ?? schema;
|
|
129
|
+
out.add(base.replace(/\.avsc$/, ""));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
};
|
|
134
|
+
const tsAvscRefs = async (path) => {
|
|
135
|
+
const out = new Set();
|
|
136
|
+
const text = await readFile(path, "utf-8");
|
|
137
|
+
for (const m of text.matchAll(/([A-Za-z0-9_-]+)\.avsc/g))
|
|
138
|
+
if (m[1])
|
|
139
|
+
out.add(m[1]);
|
|
140
|
+
return out;
|
|
141
|
+
};
|
|
142
|
+
const dirListing = async (path, spec) => {
|
|
143
|
+
const suffix = spec.suffix ?? "";
|
|
144
|
+
const entries = await readdir(path);
|
|
145
|
+
return new Set(entries
|
|
146
|
+
.filter((f) => f.endsWith(suffix))
|
|
147
|
+
.map((f) => (suffix ? f.slice(0, -suffix.length) : f)));
|
|
148
|
+
};
|
|
149
|
+
// Split a yaml path on dots outside brackets, so a filter value may itself contain dots.
|
|
150
|
+
function pathParts(p) {
|
|
151
|
+
const parts = [];
|
|
152
|
+
let cur = "";
|
|
153
|
+
let depth = 0;
|
|
154
|
+
for (const ch of p) {
|
|
155
|
+
if (ch === "[")
|
|
156
|
+
depth++;
|
|
157
|
+
if (ch === "]")
|
|
158
|
+
depth--;
|
|
159
|
+
if (ch === "." && depth === 0) {
|
|
160
|
+
parts.push(cur);
|
|
161
|
+
cur = "";
|
|
162
|
+
}
|
|
163
|
+
else
|
|
164
|
+
cur += ch;
|
|
165
|
+
}
|
|
166
|
+
parts.push(cur);
|
|
167
|
+
return parts;
|
|
168
|
+
}
|
|
169
|
+
// The nodes at a dotted path. `a.b[].c` walks every element of list `b`; `a.b[k=v].c` walks only the
|
|
170
|
+
// elements whose field `k` equals `v` (compared as strings). A missing path reaches nothing.
|
|
171
|
+
function yamlNodes(doc, path) {
|
|
172
|
+
const out = [];
|
|
173
|
+
const walk = (node, parts) => {
|
|
174
|
+
if (node === undefined || node === null)
|
|
175
|
+
return;
|
|
176
|
+
if (parts.length === 0) {
|
|
177
|
+
out.push(node);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const [head, ...rest] = parts;
|
|
181
|
+
const m = /^(.*?)\[(?:([^=\]]+)=([^\]]*))?\]$/.exec(head);
|
|
182
|
+
const key = m ? m[1] : head;
|
|
183
|
+
const next = key === "" ? node : node[key];
|
|
184
|
+
if (!m)
|
|
185
|
+
return walk(next, rest);
|
|
186
|
+
if (!Array.isArray(next))
|
|
187
|
+
return;
|
|
188
|
+
for (const el of next) {
|
|
189
|
+
if (m[2] !== undefined && String(el?.[m[2]] ?? "") !== m[3])
|
|
190
|
+
continue;
|
|
191
|
+
walk(el, rest);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
walk(doc, pathParts(path));
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
198
|
+
// yaml-path: scalar values at the given dotted paths of a YAML file (path syntax: yamlNodes).
|
|
199
|
+
// A missing path contributes nothing (the observed side is then what exists).
|
|
200
|
+
const yamlPath = async (path, spec) => {
|
|
201
|
+
const paths = spec?.paths ?? [];
|
|
202
|
+
if (paths.length === 0)
|
|
203
|
+
throw new Error("yaml-path needs `paths:` (one or more dotted paths)");
|
|
204
|
+
const doc = parseYaml(await readFile(path, "utf-8"));
|
|
205
|
+
const out = new Set();
|
|
206
|
+
for (const p of paths)
|
|
207
|
+
for (const n of yamlNodes(doc, p))
|
|
208
|
+
if (isScalar(n))
|
|
209
|
+
out.add(String(n));
|
|
210
|
+
return out;
|
|
211
|
+
};
|
|
212
|
+
// ---- value maps: both sides emit `key→value` pairs, the fallback as `*→value`, so a declared map and the
|
|
213
|
+
// code that implements it diff like any other value set. Keys and values are normalised alike: numeric
|
|
214
|
+
// literals to decimal (hex, a `L` suffix), quotes dropped, a qualified constant to its last segment.
|
|
215
|
+
const ARROW = "→";
|
|
216
|
+
function mapToken(raw, constants = new Map()) {
|
|
217
|
+
let t = raw.trim().replace(/^\((.*)\)$/, "$1").trim();
|
|
218
|
+
t = constants.get(t) ?? t;
|
|
219
|
+
if (/^-?0[xX][0-9a-fA-F]+[lL]?$/.test(t))
|
|
220
|
+
return BigInt(t.replace(/[lL]$/, "")).toString();
|
|
221
|
+
if (/^-?\d+[lL]?$/.test(t))
|
|
222
|
+
return BigInt(t.replace(/[lL]$/, "")).toString();
|
|
223
|
+
if (/^"(.*)"$/.test(t) || /^'(.*)'$/.test(t))
|
|
224
|
+
return t.slice(1, -1);
|
|
225
|
+
if (/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/.test(t))
|
|
226
|
+
return t.split(".").pop();
|
|
227
|
+
return t;
|
|
228
|
+
}
|
|
229
|
+
// yaml-value-map: the map node(s) at `paths:`, read through the key names in `map:`.
|
|
230
|
+
const yamlValueMap = async (path, spec) => {
|
|
231
|
+
const paths = spec?.paths ?? [];
|
|
232
|
+
const keys = spec?.map;
|
|
233
|
+
if (paths.length === 0 || !keys?.cases || !keys.key || !keys.value)
|
|
234
|
+
throw new Error("yaml-value-map needs `paths:` and `map: {cases, key, value, otherwise?}`");
|
|
235
|
+
const doc = parseYaml(await readFile(path, "utf-8"));
|
|
236
|
+
const out = new Set();
|
|
237
|
+
for (const p of paths) {
|
|
238
|
+
for (const node of yamlNodes(doc, p)) {
|
|
239
|
+
if (!node || typeof node !== "object")
|
|
240
|
+
continue;
|
|
241
|
+
const rec = node;
|
|
242
|
+
const cases = rec[keys.cases];
|
|
243
|
+
if (Array.isArray(cases)) {
|
|
244
|
+
for (const c of cases) {
|
|
245
|
+
const k = c?.[keys.key];
|
|
246
|
+
const v = c?.[keys.value];
|
|
247
|
+
if (isScalar(k) && isScalar(v))
|
|
248
|
+
out.add(`${mapToken(String(k))}${ARROW}${mapToken(String(v))}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const other = keys.otherwise ? rec[keys.otherwise] : undefined;
|
|
252
|
+
if (isScalar(other))
|
|
253
|
+
out.add(`*${ARROW}${mapToken(String(other))}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
};
|
|
258
|
+
// The body of the first method named `name`: from its opening brace to the matching close. Braces inside
|
|
259
|
+
// string and char literals are skipped; comments are stripped first.
|
|
260
|
+
function javaMethodBody(src, name) {
|
|
261
|
+
const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
262
|
+
const decl = new RegExp(`\\b${name}\\s*\\([^)]*\\)\\s*(?:throws[^{]*)?\\{`).exec(code);
|
|
263
|
+
if (!decl)
|
|
264
|
+
return undefined;
|
|
265
|
+
let i = decl.index + decl[0].length;
|
|
266
|
+
const start = i;
|
|
267
|
+
let depth = 1;
|
|
268
|
+
for (; i < code.length && depth > 0; i++) {
|
|
269
|
+
const ch = code[i];
|
|
270
|
+
if (ch === '"' || ch === "'") {
|
|
271
|
+
for (i++; i < code.length && code[i] !== ch; i++)
|
|
272
|
+
if (code[i] === "\\")
|
|
273
|
+
i++;
|
|
274
|
+
}
|
|
275
|
+
else if (ch === "{")
|
|
276
|
+
depth++;
|
|
277
|
+
else if (ch === "}")
|
|
278
|
+
depth--;
|
|
279
|
+
}
|
|
280
|
+
return code.slice(start, i - 1);
|
|
281
|
+
}
|
|
282
|
+
// java-value-map: the mapping one method implements. Three shapes, as written in practice:
|
|
283
|
+
// switch `case A, B -> X;` · `case A: case B: return X;` · `default -> Y;` / `default: return Y;`
|
|
284
|
+
// if-chain `if (v == A) { return X; } else if (v == B) ...` then a final `return Y;`
|
|
285
|
+
// ternary `return v == A ? X : v == B ? Z : Y;`
|
|
286
|
+
// A constant named in the method resolves through a `static final` literal in the same file. A method
|
|
287
|
+
// that maps by any other means (a lookup table, arithmetic, a range guard) yields nothing, and the check
|
|
288
|
+
// then reports every declared pair as unobserved rather than passing.
|
|
289
|
+
const javaValueMap = async (path, spec) => {
|
|
290
|
+
if (!spec?.method)
|
|
291
|
+
throw new Error("java-value-map needs `method:` (the mapping method's name)");
|
|
292
|
+
const src = await readFile(path, "utf-8");
|
|
293
|
+
const body = javaMethodBody(src, spec.method);
|
|
294
|
+
if (body === undefined)
|
|
295
|
+
throw new Error(`java-value-map: no method ${spec.method} in ${path}`);
|
|
296
|
+
const constants = new Map();
|
|
297
|
+
for (const m of src.matchAll(/\bstatic\s+final\s+[\w<>.]+\s+([A-Za-z_$][\w$]*)\s*=\s*([^;]+);/g))
|
|
298
|
+
constants.set(m[1], m[2].trim());
|
|
299
|
+
const tok = (s) => mapToken(s, constants);
|
|
300
|
+
const out = new Set();
|
|
301
|
+
const add = (k, v) => out.add(`${k === "*" ? "*" : tok(k)}${ARROW}${tok(v)}`);
|
|
302
|
+
if (/\bswitch\s*\(/.test(body)) {
|
|
303
|
+
// The two forms cannot match each other's cases: an arrow label holds no colon, and a colon case
|
|
304
|
+
// is read only when `return` follows it.
|
|
305
|
+
for (const m of body.matchAll(/\bcase\s+([^:;]+?)\s*->\s*(?:\{\s*(?:yield|return)\s+)?([^;{}]+?)\s*;/g))
|
|
306
|
+
for (const k of m[1].split(","))
|
|
307
|
+
add(k, m[2]);
|
|
308
|
+
for (const m of body.matchAll(/((?:\bcase\s+[^:]+?\s*:\s*)+)return\s+([^;]+?)\s*;/g))
|
|
309
|
+
for (const c of m[1].matchAll(/\bcase\s+([^:]+?)\s*:/g))
|
|
310
|
+
for (const k of c[1].split(","))
|
|
311
|
+
add(k, m[2]);
|
|
312
|
+
const d = /\bdefault\s*(?:->\s*(?:\{\s*(?:yield|return)\s+)?|:\s*return\s+)([^;{}]+?)\s*;/.exec(body);
|
|
313
|
+
if (d)
|
|
314
|
+
add("*", d[1]);
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
const ternary = /\breturn\s+([^;]*\?[^;]*);/.exec(body);
|
|
318
|
+
if (ternary) {
|
|
319
|
+
let rest = ternary[1];
|
|
320
|
+
for (;;) {
|
|
321
|
+
const m = /^\s*\(?\s*[\w$.]+\s*==\s*([^?]+?)\s*\)?\s*\?\s*([^:]+?)\s*:\s*([\s\S]+)$/.exec(rest);
|
|
322
|
+
if (!m)
|
|
323
|
+
break;
|
|
324
|
+
add(m[1], m[2]);
|
|
325
|
+
rest = m[3];
|
|
326
|
+
}
|
|
327
|
+
if (!/\?/.test(rest))
|
|
328
|
+
add("*", rest);
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
const branch = /(?:\belse\s+)?\bif\s*\(\s*[\w$.]+\s*==\s*([^)]+?)\s*\)\s*\{?\s*return\s+([^;]+?)\s*;\s*\}?/g;
|
|
332
|
+
for (const m of body.matchAll(branch))
|
|
333
|
+
add(m[1], m[2]);
|
|
334
|
+
if (out.size > 0) {
|
|
335
|
+
// The fallback: the first return left once the matched branches are removed (a trailing return, or
|
|
336
|
+
// an `else { return …; }`).
|
|
337
|
+
const fb = /\breturn\s+([^;]+?)\s*;/.exec(body.replace(branch, ""));
|
|
338
|
+
if (fb)
|
|
339
|
+
add("*", fb[1]);
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
};
|
|
343
|
+
// regex-captures: capture group 1 of every match of `pattern` in one file. Covers setter calls, builder
|
|
344
|
+
// calls, assignments — any accounting a coding style expresses — without one extractor per style.
|
|
345
|
+
const regexCaptures = async (path, spec) => {
|
|
346
|
+
if (!spec?.pattern)
|
|
347
|
+
throw new Error("regex-captures needs `pattern:` (a regex with one capture group)");
|
|
348
|
+
const re = new RegExp(spec.pattern, "gm");
|
|
349
|
+
const out = new Set();
|
|
350
|
+
for (const m of (await readFile(path, "utf-8")).matchAll(re))
|
|
351
|
+
if (m[1])
|
|
352
|
+
out.add(m[1]);
|
|
353
|
+
return out;
|
|
354
|
+
};
|
|
355
|
+
const REGISTRY = {
|
|
356
|
+
"markdown-provider-signals": markdownProviderSignals,
|
|
357
|
+
"markdown-provider-fields": markdownProviderSignals, // same fn; matches "Provider Signal" | "Provider Field"
|
|
358
|
+
"java-string-const": javaStringConst,
|
|
359
|
+
"java-field-names": javaFieldNames,
|
|
360
|
+
"yaml-produced-events": yamlProducedEvents,
|
|
361
|
+
"ts-avsc-refs": tsAvscRefs,
|
|
362
|
+
"dir-listing": dirListing,
|
|
363
|
+
"yaml-path": yamlPath,
|
|
364
|
+
"regex-captures": regexCaptures,
|
|
365
|
+
"yaml-value-map": yamlValueMap,
|
|
366
|
+
"java-value-map": javaValueMap,
|
|
367
|
+
};
|
|
368
|
+
// ---------------------------------------------------------------- diff core
|
|
369
|
+
function norm(s, mode) {
|
|
370
|
+
if (mode === "leaf") {
|
|
371
|
+
// Match a nested/dotted declared path against a leaf code identifier: take the last dotted
|
|
372
|
+
// segment, drop array markers, then lower-nosep. `statusInfo.truStatus` ~ `truStatus`.
|
|
373
|
+
const leaf = s.split(".").pop() ?? s;
|
|
374
|
+
return leaf.replace(/\[\]/g, "").replace(/[-_./ ]/g, "").toLowerCase();
|
|
375
|
+
}
|
|
376
|
+
return mode === "lower-nosep" ? s.replace(/[-_./ ]/g, "").toLowerCase() : s;
|
|
377
|
+
}
|
|
378
|
+
function collapsedGroups(values, mode) {
|
|
379
|
+
const byToken = new Map();
|
|
380
|
+
for (const v of values) {
|
|
381
|
+
const k = norm(v, mode);
|
|
382
|
+
byToken.set(k, [...(byToken.get(k) ?? []), v]);
|
|
383
|
+
}
|
|
384
|
+
return [...byToken]
|
|
385
|
+
.filter(([, vs]) => vs.length > 1)
|
|
386
|
+
.map(([token, vs]) => ({ token, values: vs.sort() }))
|
|
387
|
+
.sort((a, b) => a.token.localeCompare(b.token));
|
|
388
|
+
}
|
|
389
|
+
async function extract(targetRepo, codeRoot, spec) {
|
|
390
|
+
const specs = Array.isArray(spec) ? spec : [spec];
|
|
391
|
+
const values = new Set();
|
|
392
|
+
let ok = true;
|
|
393
|
+
for (const sp of specs) {
|
|
394
|
+
const fn = REGISTRY[sp.extractor];
|
|
395
|
+
if (!fn)
|
|
396
|
+
throw new Error(`unknown extractor: ${sp.extractor}`);
|
|
397
|
+
const path = sp.source ? resolveSource(targetRepo, sp.source, codeRoot) : "";
|
|
398
|
+
if (!path || !existsSync(path)) {
|
|
399
|
+
ok = false;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
for (const v of await fn(path, sp))
|
|
403
|
+
values.add(v);
|
|
404
|
+
}
|
|
405
|
+
return { values, ok };
|
|
406
|
+
}
|
|
407
|
+
async function runCheck(targetRepo, codeRoot, chk, member) {
|
|
408
|
+
const dRes = await extract(targetRepo, codeRoot, chk.declared);
|
|
409
|
+
const oRes = await extract(targetRepo, codeRoot, chk.observed);
|
|
410
|
+
if (!dRes.ok || !oRes.ok)
|
|
411
|
+
return null; // SKIP — a source is absent here
|
|
412
|
+
const mode = chk.normalise ?? "none";
|
|
413
|
+
const dMap = new Map([...dRes.values].map((x) => [norm(x, mode), x]));
|
|
414
|
+
const oMap = new Map([...oRes.values].map((x) => [norm(x, mode), x]));
|
|
415
|
+
const dCollapsed = collapsedGroups(dRes.values, mode);
|
|
416
|
+
const oCollapsed = collapsedGroups(oRes.values, mode);
|
|
417
|
+
const D = new Set(dMap.keys());
|
|
418
|
+
const O = new Set(oMap.keys());
|
|
419
|
+
const matched = [...D].filter((k) => O.has(k));
|
|
420
|
+
const dOnly = [...D].filter((k) => !O.has(k));
|
|
421
|
+
const oOnly = [...O].filter((k) => !D.has(k));
|
|
422
|
+
const denom = (chk.denom ?? "union") === "declared" ? D.size : new Set([...D, ...O]).size;
|
|
423
|
+
return {
|
|
424
|
+
id: chk.id,
|
|
425
|
+
target: chk.target,
|
|
426
|
+
declared: D.size,
|
|
427
|
+
observed: O.size,
|
|
428
|
+
matched: matched.length,
|
|
429
|
+
conformancePct: denom ? Math.round((matched.length / denom) * 100) : 0,
|
|
430
|
+
denom: chk.denom ?? "union",
|
|
431
|
+
matchedNames: matched.map((k) => dMap.get(k)).sort(),
|
|
432
|
+
declaredNotObserved: dOnly.map((k) => dMap.get(k)).sort(),
|
|
433
|
+
observedNotDeclared: oOnly.map((k) => oMap.get(k)).sort(),
|
|
434
|
+
candidates: chk.candidates ?? {},
|
|
435
|
+
needsTriage: dOnly.length > 0 || oOnly.length > 0,
|
|
436
|
+
...(dCollapsed.length ? { declaredCollapsed: dCollapsed } : {}),
|
|
437
|
+
...(oCollapsed.length ? { observedCollapsed: oCollapsed } : {}),
|
|
438
|
+
...(member?.repo ? { implementationRepo: member.repo } : {}),
|
|
439
|
+
...(member?.role ? { role: member.role } : {}),
|
|
440
|
+
...(member?.lifecycle ? { lifecycle: member.lifecycle } : {}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
// ---------------------------------------------------------------- output
|
|
444
|
+
function printHuman(r, out) {
|
|
445
|
+
out(`\n=== ${r.id} [${r.target}] ===\n`);
|
|
446
|
+
// An empty denominator is nothing to measure, not 0% conformance.
|
|
447
|
+
const denomSize = r.denom === "declared"
|
|
448
|
+
? r.declared
|
|
449
|
+
: r.matched + r.declaredNotObserved.length + r.observedNotDeclared.length;
|
|
450
|
+
const pct = denomSize === 0 ? `n/a (0/0 — nothing declared or observed)` : `${r.conformancePct}%`;
|
|
451
|
+
out(`declared=${r.declared} observed=${r.observed} matched=${r.matched} ` +
|
|
452
|
+
`conformance=${pct} (matched/${r.denom})\n`);
|
|
453
|
+
if (r.matchedNames.length)
|
|
454
|
+
out(` [OK] matched: ${r.matchedNames.join(", ")}\n`);
|
|
455
|
+
for (const [side, groups] of [["declared", r.declaredCollapsed], ["observed", r.observedCollapsed]]) {
|
|
456
|
+
if (!groups?.length)
|
|
457
|
+
continue;
|
|
458
|
+
const n = groups.reduce((a, g) => a + g.values.length, 0);
|
|
459
|
+
out(` [i] ${side} collapsed: ${n} distinct value(s) counted as ${groups.length} token(s) — ` +
|
|
460
|
+
`a match on a token credits every value in its group:\n`);
|
|
461
|
+
for (const g of groups)
|
|
462
|
+
out(` ${g.token} <- ${g.values.join(", ")}\n`);
|
|
463
|
+
}
|
|
464
|
+
if (r.declaredNotObserved.length) {
|
|
465
|
+
out(` [!] declared-not-observed (${r.declaredNotObserved.length}) -> NEEDS-TRIAGE ` +
|
|
466
|
+
`[${r.candidates.declared_only ?? ""}]:\n`);
|
|
467
|
+
for (const m of r.declaredNotObserved)
|
|
468
|
+
out(` - ${m}\n`);
|
|
469
|
+
}
|
|
470
|
+
if (r.observedNotDeclared.length) {
|
|
471
|
+
out(` [!] observed-not-declared (${r.observedNotDeclared.length}) -> NEEDS-TRIAGE ` +
|
|
472
|
+
`[${r.candidates.observed_only ?? ""}]:\n`);
|
|
473
|
+
for (const m of r.observedNotDeclared)
|
|
474
|
+
out(` - ${m}\n`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function printReconcile(chk, r, out) {
|
|
478
|
+
if (!chk.reconcile || r.target !== "binding-vs-reality")
|
|
479
|
+
return;
|
|
480
|
+
const rc = chk.reconcile;
|
|
481
|
+
out(`\n--- reconcile ${r.id} -> regenerate \`${rc.block}\` in ${rc.binding} (ADR 0181 §2.3) ---\n`);
|
|
482
|
+
out(`# generated from the observed-set (${r.observed} schemas); the binding cannot drift if generated.\n`);
|
|
483
|
+
if (rc.note)
|
|
484
|
+
out(`# NOTE: ${rc.note}\n`);
|
|
485
|
+
const full = [...new Set([...r.matchedNames, ...r.observedNotDeclared])].sort();
|
|
486
|
+
out(`${rc.block.split(".")[0]}:\n`);
|
|
487
|
+
out(` produces: # ${full.length} schemas (was ${r.declared} declared)\n`);
|
|
488
|
+
for (const name of full)
|
|
489
|
+
out(` - schema: party/events/${name}.avsc\n`);
|
|
490
|
+
if (r.declaredNotObserved.length) {
|
|
491
|
+
out(` # DROP (dangling — no such schema in code/contracts): ${r.declaredNotObserved.join(", ")}\n`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function planChecks(opts, stdout) {
|
|
495
|
+
// Resolution order: explicit --config wins; else the ADR 0181 binding (checks derived from it);
|
|
496
|
+
// else the conventional .sdd/conformance-checks.yaml; else nothing to do.
|
|
497
|
+
if (!opts.config && hasBinding(opts.targetRepo)) {
|
|
498
|
+
const derived = await deriveChecksFromBinding(opts.targetRepo);
|
|
499
|
+
return derived.map((d) => ({ check: d.check, member: d.member }));
|
|
500
|
+
}
|
|
501
|
+
const configPath = opts.config
|
|
502
|
+
? resolveSource(opts.targetRepo, opts.config, opts.codeRoot)
|
|
503
|
+
: resolve(opts.targetRepo, ".sdd/conformance-checks.yaml");
|
|
504
|
+
if (!existsSync(configPath)) {
|
|
505
|
+
stdout(`[skip] no spec↔implementation binding (.sdd/implementation-binding.yaml or .sdd/implementation-bindings/, ADR 0181) and no ` +
|
|
506
|
+
`checks file at ${configPath}\n`);
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
const parsed = parseYaml(await readFile(configPath, "utf-8"));
|
|
510
|
+
return (parsed?.checks ?? []).map((check) => ({ check }));
|
|
511
|
+
}
|
|
512
|
+
export async function runConformance(opts, stdout = (line) => process.stdout.write(line)) {
|
|
513
|
+
let planned = await planChecks(opts, stdout);
|
|
514
|
+
// Under --strict, a run that measured nothing must not pass the gate (empty-set
|
|
515
|
+
// sweep, R2, ruled 2026-09-29): no binding, a --check filter that matched no
|
|
516
|
+
// check, or every check SKIPPED all used to exit 0 with no verdict at all.
|
|
517
|
+
if (planned === null) {
|
|
518
|
+
if (opts.strict) {
|
|
519
|
+
stdout(`[FAIL] conformance --strict: 0 checks planned — nothing was measured, which is not a pass\n`);
|
|
520
|
+
return { exitCode: 1, results: [], skipped: [] };
|
|
521
|
+
}
|
|
522
|
+
return { exitCode: 0, results: [], skipped: [] };
|
|
523
|
+
}
|
|
524
|
+
const plannedBeforeFilter = planned.length;
|
|
525
|
+
if (opts.check)
|
|
526
|
+
planned = planned.filter((p) => p.check.id === opts.check);
|
|
527
|
+
const results = [];
|
|
528
|
+
const skipped = [];
|
|
529
|
+
const reconcileByline = new Map();
|
|
530
|
+
for (const { check, member } of planned) {
|
|
531
|
+
const r = await runCheck(opts.targetRepo, opts.codeRoot, check, member);
|
|
532
|
+
if (r) {
|
|
533
|
+
results.push(r);
|
|
534
|
+
reconcileByline.set(r.id, check);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
skipped.push(check.id);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (opts.json) {
|
|
541
|
+
stdout(JSON.stringify({ checks: results, skipped }, null, 2) + "\n");
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
stdout("Conformance-reverse (advisory; disposition is triage — ADR 0180 §2.3).\n");
|
|
545
|
+
for (const r of results) {
|
|
546
|
+
printHuman(r, stdout);
|
|
547
|
+
if (opts.reconcile) {
|
|
548
|
+
const chk = reconcileByline.get(r.id);
|
|
549
|
+
if (chk)
|
|
550
|
+
printReconcile(chk, r, stdout);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const id of skipped)
|
|
554
|
+
stdout(`\n=== ${id} === SKIP (source not found)\n`);
|
|
555
|
+
if (!opts.strict)
|
|
556
|
+
stdout("\n--- exit 0 (advisory). Use --strict to gate on un-triaged drift. ---\n");
|
|
557
|
+
}
|
|
558
|
+
if (opts.emitFinding) {
|
|
559
|
+
const runDate = opts.runDate ?? new Date().toISOString().slice(0, 10);
|
|
560
|
+
const path = await writeFindings(opts.emitFinding, results, runDate);
|
|
561
|
+
if (!opts.json)
|
|
562
|
+
stdout(`\n[OK] wrote conformance_finding catalogue: ${path}\n`);
|
|
563
|
+
}
|
|
564
|
+
const anyTriage = results.some((r) => r.needsTriage);
|
|
565
|
+
const noneRan = results.length === 0;
|
|
566
|
+
if (!opts.json) {
|
|
567
|
+
const filterNote = opts.check && planned.length === 0
|
|
568
|
+
? ` — --check "${opts.check}" matched none of ${plannedBeforeFilter}`
|
|
569
|
+
: "";
|
|
570
|
+
stdout(`\n[metric] conformance: ${results.length} of ${planned.length} planned check(s) ran, ` +
|
|
571
|
+
`${skipped.length} skipped${filterNote}\n`);
|
|
572
|
+
if (opts.strict && noneRan) {
|
|
573
|
+
stdout(`[FAIL] conformance --strict: 0 checks ran — nothing was measured, which is not a pass\n`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return { exitCode: opts.strict && (anyTriage || noneRan) ? 1 : 0, results, skipped };
|
|
577
|
+
}
|
|
578
|
+
//# sourceMappingURL=index.js.map
|