@ybotok/loam 0.0.0 → 0.1.0-beta.1
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/CHANGELOG.md +89 -0
- package/COMPARISON.md +63 -0
- package/LICENSE +21 -0
- package/MIGRATING-from-OpenSpec.md +202 -0
- package/README.md +241 -9
- package/SCHEMA.md +425 -0
- package/SECURITY.md +9 -0
- package/dist/cli.js +138 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/adopt.js +226 -0
- package/dist/commands/adopt.js.map +1 -0
- package/dist/commands/archive.js +1297 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/delta.js +361 -0
- package/dist/commands/delta.js.map +1 -0
- package/dist/commands/dependencies.js +78 -0
- package/dist/commands/dependencies.js.map +1 -0
- package/dist/commands/doctor.js +85 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/gherkin.js +270 -0
- package/dist/commands/gherkin.js.map +1 -0
- package/dist/commands/init.js +250 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.js +361 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/migrate-openspec.js +933 -0
- package/dist/commands/migrate-openspec.js.map +1 -0
- package/dist/commands/new.js +412 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/rebase.js +358 -0
- package/dist/commands/rebase.js.map +1 -0
- package/dist/commands/show.js +325 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/status.js +270 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/unarchive.js +310 -0
- package/dist/commands/unarchive.js.map +1 -0
- package/dist/commands/validate.js +1609 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/verify.js +759 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/vouch.js +325 -0
- package/dist/commands/vouch.js.map +1 -0
- package/dist/core/agent.js +1823 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/agents-stamp.js +68 -0
- package/dist/core/agents-stamp.js.map +1 -0
- package/dist/core/arch.js +104 -0
- package/dist/core/arch.js.map +1 -0
- package/dist/core/brief.js +733 -0
- package/dist/core/brief.js.map +1 -0
- package/dist/core/coherence.js +553 -0
- package/dist/core/coherence.js.map +1 -0
- package/dist/core/config.js +172 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/delta.js +457 -0
- package/dist/core/delta.js.map +1 -0
- package/dist/core/dependencies.js +352 -0
- package/dist/core/dependencies.js.map +1 -0
- package/dist/core/docs.js +143 -0
- package/dist/core/docs.js.map +1 -0
- package/dist/core/doctor.js +544 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/fleet-context.js +272 -0
- package/dist/core/fleet-context.js.map +1 -0
- package/dist/core/frontmatter.js +203 -0
- package/dist/core/frontmatter.js.map +1 -0
- package/dist/core/gherkin.js +521 -0
- package/dist/core/gherkin.js.map +1 -0
- package/dist/core/health.js +71 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/ids.js +93 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/issue.js +9 -0
- package/dist/core/issue.js.map +1 -0
- package/dist/core/json.js +67 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/likec4.js +474 -0
- package/dist/core/likec4.js.map +1 -0
- package/dist/core/openapi-merge.js +519 -0
- package/dist/core/openapi-merge.js.map +1 -0
- package/dist/core/openapi.js +243 -0
- package/dist/core/openapi.js.map +1 -0
- package/dist/core/openspec-inventory.js +1403 -0
- package/dist/core/openspec-inventory.js.map +1 -0
- package/dist/core/path-safety.js +109 -0
- package/dist/core/path-safety.js.map +1 -0
- package/dist/core/provenance.js +695 -0
- package/dist/core/provenance.js.map +1 -0
- package/dist/core/repo.js +362 -0
- package/dist/core/repo.js.map +1 -0
- package/dist/core/report.js +38 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/results.js +201 -0
- package/dist/core/results.js.map +1 -0
- package/dist/core/spec.js +481 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/staging.js +783 -0
- package/dist/core/staging.js.map +1 -0
- package/dist/core/status.js +1015 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/verify.js +793 -0
- package/dist/core/verify.js.map +1 -0
- package/dist/core/version.js +8 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +63 -4
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loam verify --results` — the cucumber report as an answer sheet.
|
|
3
|
+
*
|
|
4
|
+
* `loam gherkin` tags every generated scenario `@loam-digest-<16hex>`, and
|
|
5
|
+
* cucumber's JSON format carries tags per scenario — so a test report says,
|
|
6
|
+
* mechanically, which spec scenarios ran green. This module reads that report
|
|
7
|
+
* and answers `scenario.tested` claims from it. The digest is the ONLY
|
|
8
|
+
* identity: a claim's `digest` and the tag are the same 16 hex of the same
|
|
9
|
+
* body hash, and names match nothing — rewording a scenario breaks the match,
|
|
10
|
+
* which is the point, because an agent must not be able to SAY a scenario is
|
|
11
|
+
* tested; only a green run may.
|
|
12
|
+
*
|
|
13
|
+
* That identity is CONTENT, and content stops one step short of a fleet: two
|
|
14
|
+
* services whose specs word a scenario identically share a digest, so one
|
|
15
|
+
* repository's report matches the other repository's claim. Those claims are
|
|
16
|
+
* refused rather than answered — see {@link contestedDigests}.
|
|
17
|
+
*
|
|
18
|
+
* Parsing is deliberately tolerant of everything except the shape it matches
|
|
19
|
+
* on. The format is what cucumber-js `--format json`, cucumber-jvm, behave and
|
|
20
|
+
* SpecFlow emit — an array of features, each `elements[]` (scenarios) with
|
|
21
|
+
* `name`, `tags[] {name}` and `steps[] {result {status}}`, plus the two
|
|
22
|
+
* places those dialects put a failure the steps never see: cucumber-jvm
|
|
23
|
+
* reports @Before/@After hook results in per-element `before[]`/`after[]`
|
|
24
|
+
* arrays (a teardown assertion fails there while every step stays `passed`),
|
|
25
|
+
* and behave carries an element-level `status` that a hook failure flips.
|
|
26
|
+
* Only those fields are contract: unknown fields, tagless elements
|
|
27
|
+
* (backgrounds, hand-written scenarios) and stray entries are skipped without
|
|
28
|
+
* comment. What is NOT tolerated is a file that is not that array at all: a
|
|
29
|
+
* report loam cannot recognize must refuse rather than quietly answer every
|
|
30
|
+
* claim "not found".
|
|
31
|
+
*/
|
|
32
|
+
import { DIGEST_TAG_RE, scenarioDigestTag } from "./gherkin.js";
|
|
33
|
+
/**
|
|
34
|
+
* Read a parsed cucumber JSON document down to its digest-tagged scenarios.
|
|
35
|
+
* `reportName` is how the caller spelled the file, for the refusal message.
|
|
36
|
+
*/
|
|
37
|
+
export function readCucumberReport(doc, reportName) {
|
|
38
|
+
if (!Array.isArray(doc)) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
message: `${reportName} is not a cucumber JSON report — expected the top-level array of features ` +
|
|
42
|
+
"that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits.",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const scenarios = [];
|
|
46
|
+
for (const feature of doc) {
|
|
47
|
+
if (!isRecord(feature))
|
|
48
|
+
continue;
|
|
49
|
+
const featureName = str(feature["uri"]) ?? str(feature["name"]) ?? "(unnamed feature)";
|
|
50
|
+
const elements = feature["elements"];
|
|
51
|
+
if (!Array.isArray(elements))
|
|
52
|
+
continue;
|
|
53
|
+
for (const el of elements) {
|
|
54
|
+
if (!isRecord(el))
|
|
55
|
+
continue;
|
|
56
|
+
const digests = [];
|
|
57
|
+
const tags = el["tags"];
|
|
58
|
+
if (Array.isArray(tags)) {
|
|
59
|
+
for (const t of tags) {
|
|
60
|
+
const name = typeof t === "string" ? t : isRecord(t) ? str(t["name"]) : undefined;
|
|
61
|
+
const m = name === undefined ? null : DIGEST_TAG_RE.exec(name);
|
|
62
|
+
if (m)
|
|
63
|
+
digests.push(m[1]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// No digest tag, nothing to match: a background, a hand-written
|
|
67
|
+
// scenario, another tool's output. Invisible here, deliberately.
|
|
68
|
+
if (digests.length === 0)
|
|
69
|
+
continue;
|
|
70
|
+
const resultStatuses = (raw) => {
|
|
71
|
+
if (!Array.isArray(raw))
|
|
72
|
+
return [];
|
|
73
|
+
return raw.map((s) => {
|
|
74
|
+
const status = isRecord(s) && isRecord(s["result"]) ? str(s["result"]["status"]) : undefined;
|
|
75
|
+
return (status ?? "unknown").toLowerCase();
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const steps = resultStatuses(el["steps"]);
|
|
79
|
+
// cucumber-jvm's @Before/@After hooks: separate arrays, same result shape.
|
|
80
|
+
const hooks = [...resultStatuses(el["before"]), ...resultStatuses(el["after"])];
|
|
81
|
+
// behave's element-level status — a hook failure flips it while the
|
|
82
|
+
// steps that already ran keep "passed".
|
|
83
|
+
const status = str(el["status"])?.toLowerCase();
|
|
84
|
+
scenarios.push({
|
|
85
|
+
where: `${featureName} › ${str(el["name"]) ?? "(unnamed scenario)"}`,
|
|
86
|
+
digests,
|
|
87
|
+
steps,
|
|
88
|
+
hooks,
|
|
89
|
+
...(status === undefined ? {} : { status }),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, scenarios };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The digests that more than one service claims, with the services that claim
|
|
97
|
+
* them — a scenario worded identically in two services' specs.
|
|
98
|
+
*
|
|
99
|
+
* The digest is a hash of the scenario body and of nothing else, so `loam
|
|
100
|
+
* gherkin` stamps the same tag into both repositories and a report from either
|
|
101
|
+
* one matches both claims. A report says which scenario ran; it never says
|
|
102
|
+
* whose suite ran it, and the two repositories never ran each other's tests.
|
|
103
|
+
* loam has no way to tell them apart, so it does not choose: the answer is
|
|
104
|
+
* refused, with the services named, and `--service` is the way to give a report
|
|
105
|
+
* an owner (it narrows the checklist to one repository's claims before the
|
|
106
|
+
* matching starts, so nothing here is contested).
|
|
107
|
+
*
|
|
108
|
+
* Only the SERVICE boundary counts. One service's spec repeating a scenario
|
|
109
|
+
* word for word is genuinely one test — that is the documented meaning of the
|
|
110
|
+
* stamp — and both claims may be answered by the one run.
|
|
111
|
+
*/
|
|
112
|
+
export function contestedDigests(claims) {
|
|
113
|
+
const owners = new Map();
|
|
114
|
+
for (const c of claims) {
|
|
115
|
+
if (c.digest === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
const seen = owners.get(c.digest) ?? new Set();
|
|
118
|
+
seen.add(c.subject);
|
|
119
|
+
owners.set(c.digest, seen);
|
|
120
|
+
}
|
|
121
|
+
const contested = new Map();
|
|
122
|
+
for (const [digest, services] of owners) {
|
|
123
|
+
if (services.size > 1)
|
|
124
|
+
contested.set(digest, [...services].sort());
|
|
125
|
+
}
|
|
126
|
+
return contested;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Answer every claim from the report — the runner's half of the record. Meant
|
|
130
|
+
* for `scenario.tested` claims (the only kind carrying a digest); a claim
|
|
131
|
+
* without one is answered `unconfirmed` rather than skipped, because silence
|
|
132
|
+
* must never read as checked.
|
|
133
|
+
*
|
|
134
|
+
* Confirmation is strict on purpose: every matching occurrence — a digest the
|
|
135
|
+
* report holds twice is a re-run, and all of them count — ran at least one
|
|
136
|
+
* step, every step `passed`, every before/after hook `passed`, and the
|
|
137
|
+
* element-level status (when the dialect reports one) is `passed`. One failed
|
|
138
|
+
* occurrence wins as failure, a skipped-only run is "skipped" not green, and
|
|
139
|
+
* no match at all is "not found in report". A digest two services share is
|
|
140
|
+
* refused before any of that: see {@link contestedDigests}.
|
|
141
|
+
*/
|
|
142
|
+
export function runnerAnswers(claims, scenarios, reportName) {
|
|
143
|
+
const contested = contestedDigests(claims);
|
|
144
|
+
return claims.map((c) => {
|
|
145
|
+
const rivals = c.digest === undefined ? undefined : contested.get(c.digest);
|
|
146
|
+
if (rivals !== undefined) {
|
|
147
|
+
return answer(c.id, "unconfirmed", [], `${scenarioDigestTag(c.digest)} is worded identically in ${rivals.join(", ")}, so ${reportName} cannot say whose suite ran it. ` +
|
|
148
|
+
"Record each service's claims from its own repo with --service.");
|
|
149
|
+
}
|
|
150
|
+
const runs = c.digest === undefined ? [] : scenarios.filter((s) => s.digests.includes(c.digest));
|
|
151
|
+
if (runs.length === 0) {
|
|
152
|
+
const tag = c.digest === undefined ? "a digest tag" : scenarioDigestTag(c.digest);
|
|
153
|
+
return answer(c.id, "unconfirmed", [], `not found in report — no scenario in ${reportName} carries ${tag}`);
|
|
154
|
+
}
|
|
155
|
+
const failing = runs.filter((r) => !passed(r));
|
|
156
|
+
if (failing.length === 0) {
|
|
157
|
+
return answer(c.id, "confirmed", runs.map((r) => `${reportName}: ${r.where}`));
|
|
158
|
+
}
|
|
159
|
+
const first = failing[0];
|
|
160
|
+
const rerun = runs.length > 1 ? ` — ${failing.length} of ${runs.length} matching runs failed` : "";
|
|
161
|
+
return answer(c.id, "unconfirmed", [], `${reason(first)} (${reportName}: ${first.where})${rerun}`);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function answer(id, verdict, evidence, note) {
|
|
165
|
+
return { id, verdict, evidence, ...(note === undefined ? {} : { note }), answered_by: "runner" };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Green means green: at least one step, every step `passed`, every before/
|
|
169
|
+
* after hook `passed`, and the element-level status (when the dialect carries
|
|
170
|
+
* one) `passed` too. The last two exist because a run the runner itself
|
|
171
|
+
* reports as FAILED — a teardown assertion in an @After hook, exactly the
|
|
172
|
+
* shape the arch axis's outbox checks take — used to read as confirmed here:
|
|
173
|
+
* the steps all passed, and the hook failure lived in fields nobody read.
|
|
174
|
+
*/
|
|
175
|
+
function passed(r) {
|
|
176
|
+
return (r.steps.length > 0 &&
|
|
177
|
+
r.steps.every((s) => s === "passed") &&
|
|
178
|
+
r.hooks.every((h) => h === "passed") &&
|
|
179
|
+
(r.status === undefined || r.status === "passed"));
|
|
180
|
+
}
|
|
181
|
+
/** Why a run did not pass, naming the step, hook or element status that says so. */
|
|
182
|
+
function reason(r) {
|
|
183
|
+
if (r.steps.length === 0)
|
|
184
|
+
return "no steps ran";
|
|
185
|
+
const i = r.steps.findIndex((s) => s !== "passed" && s !== "skipped");
|
|
186
|
+
if (i >= 0)
|
|
187
|
+
return `${r.steps[i]} at step ${i + 1}`;
|
|
188
|
+
if (r.steps.some((s) => s === "skipped"))
|
|
189
|
+
return "skipped";
|
|
190
|
+
const h = r.hooks.findIndex((s) => s !== "passed");
|
|
191
|
+
if (h >= 0)
|
|
192
|
+
return `${r.hooks[h]} in a before/after hook`;
|
|
193
|
+
return `scenario status '${r.status ?? "unknown"}'`;
|
|
194
|
+
}
|
|
195
|
+
function isRecord(v) {
|
|
196
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
197
|
+
}
|
|
198
|
+
function str(v) {
|
|
199
|
+
return typeof v === "string" && v.trim().length > 0 ? v : undefined;
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=results.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"results.js","sourceRoot":"","sources":["../../src/core/results.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAqBhE;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAY,EAAE,UAAkB;IACjE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QACxB,OAAO;YACL,EAAE,EAAE,KAAK;YACT,OAAO,EACL,GAAG,UAAU,4EAA4E;gBACzF,6EAA6E;SAChF,CAAC;IACJ,CAAC;IACD,MAAM,SAAS,GAAqB,EAAE,CAAC;IACvC,KAAK,MAAM,OAAO,IAAI,GAAG,EAAE,CAAC;QAC1B,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC;YAAE,SAAS;QACjC,MAAM,WAAW,GAAG,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,mBAAmB,CAAC;QACvF,MAAM,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;QACrC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC;YAAE,SAAS;QACvC,KAAK,MAAM,EAAE,IAAI,QAAQ,EAAE,CAAC;YAC1B,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAAE,SAAS;YAC5B,MAAM,OAAO,GAAa,EAAE,CAAC;YAC7B,MAAM,IAAI,GAAG,EAAE,CAAC,MAAM,CAAC,CAAC;YACxB,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;gBACxB,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;oBACrB,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;oBAClF,MAAM,CAAC,GAAG,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;oBAC/D,IAAI,CAAC;wBAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAE,CAAC,CAAC;gBAC7B,CAAC;YACH,CAAC;YACD,gEAAgE;YAChE,iEAAiE;YACjE,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;gBAAE,SAAS;YACnC,MAAM,cAAc,GAAG,CAAC,GAAY,EAAY,EAAE;gBAChD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;oBAAE,OAAO,EAAE,CAAC;gBACnC,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;oBACnB,MAAM,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;oBAC7F,OAAO,CAAC,MAAM,IAAI,SAAS,CAAC,CAAC,WAAW,EAAE,CAAC;gBAC7C,CAAC,CAAC,CAAC;YACL,CAAC,CAAC;YACF,MAAM,KAAK,GAAG,cAAc,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC;YAC1C,2EAA2E;YAC3E,MAAM,KAAK,GAAG,CAAC,GAAG,cAAc,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,cAAc,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;YAChF,oEAAoE;YACpE,wCAAwC;YACxC,MAAM,MAAM,GAAG,GAAG,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC;YAChD,SAAS,CAAC,IAAI,CAAC;gBACb,KAAK,EAAE,GAAG,WAAW,MAAM,GAAG,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,oBAAoB,EAAE;gBACpE,OAAO;gBACP,KAAK;gBACL,KAAK;gBACL,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC;aAC5C,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AACjC,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAwB;IACvD,MAAM,MAAM,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC9C,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACvB,IAAI,CAAC,CAAC,MAAM,KAAK,SAAS;YAAE,SAAS;QACrC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,IAAI,GAAG,EAAU,CAAC;QACvD,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;QACpB,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAC7B,CAAC;IACD,MAAM,SAAS,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC9C,KAAK,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,MAAM,EAAE,CAAC;QACxC,IAAI,QAAQ,CAAC,IAAI,GAAG,CAAC;YAAE,SAAS,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,GAAG,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IACrE,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,aAAa,CAC3B,MAAe,EACf,SAA2B,EAC3B,UAAkB;IAElB,MAAM,SAAS,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC;IAC3C,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACtB,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;QAC5E,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,OAAO,MAAM,CACX,CAAC,CAAC,EAAE,EACJ,aAAa,EACb,EAAE,EACF,GAAG,iBAAiB,CAAC,CAAC,CAAC,MAAO,CAAC,6BAA6B,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,UAAU,kCAAkC;gBAC/H,gEAAgE,CACnE,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,GAAG,CAAC,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAO,CAAC,CAAC,CAAC;QAClG,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACtB,MAAM,GAAG,GAAG,CAAC,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YAClF,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE,wCAAwC,UAAU,YAAY,GAAG,EAAE,CAAC,CAAC;QAC9G,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QAC/C,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACzB,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,UAAU,KAAK,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QACjF,CAAC;QACD,MAAM,KAAK,GAAG,OAAO,CAAC,CAAC,CAAE,CAAC;QAC1B,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,OAAO,CAAC,MAAM,OAAO,IAAI,CAAC,MAAM,uBAAuB,CAAC,CAAC,CAAC,EAAE,CAAC;QACnG,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,KAAK,UAAU,KAAK,KAAK,CAAC,KAAK,IAAI,KAAK,EAAE,CAAC,CAAC;IACrG,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,MAAM,CAAC,EAAU,EAAE,OAA0B,EAAE,QAAkB,EAAE,IAAa;IACvF,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,CAAC;AACnG,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,MAAM,CAAC,CAAiB;IAC/B,OAAO,CACL,CAAC,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC;QAClB,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,QAAQ,CAAC;QACpC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,QAAQ,CAAC;QACpC,CAAC,CAAC,CAAC,MAAM,KAAK,SAAS,IAAI,CAAC,CAAC,MAAM,KAAK,QAAQ,CAAC,CAClD,CAAC;AACJ,CAAC;AAED,oFAAoF;AACpF,SAAS,MAAM,CAAC,CAAiB;IAC/B,IAAI,CAAC,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,cAAc,CAAC;IAChD,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,SAAS,CAAC,CAAC;IACtE,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC;IACpD,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC;QAAE,OAAO,SAAS,CAAC;IAC3D,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,yBAAyB,CAAC;IAC1D,OAAO,oBAAoB,CAAC,CAAC,MAAM,IAAI,SAAS,GAAG,CAAC;AACtD,CAAC;AAED,SAAS,QAAQ,CAAC,CAAU;IAC1B,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAClE,CAAC;AAED,SAAS,GAAG,CAAC,CAAU;IACrB,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AACtE,CAAC","sourcesContent":["/**\n * `loam verify --results` — the cucumber report as an answer sheet.\n *\n * `loam gherkin` tags every generated scenario `@loam-digest-<16hex>`, and\n * cucumber's JSON format carries tags per scenario — so a test report says,\n * mechanically, which spec scenarios ran green. This module reads that report\n * and answers `scenario.tested` claims from it. The digest is the ONLY\n * identity: a claim's `digest` and the tag are the same 16 hex of the same\n * body hash, and names match nothing — rewording a scenario breaks the match,\n * which is the point, because an agent must not be able to SAY a scenario is\n * tested; only a green run may.\n *\n * That identity is CONTENT, and content stops one step short of a fleet: two\n * services whose specs word a scenario identically share a digest, so one\n * repository's report matches the other repository's claim. Those claims are\n * refused rather than answered — see {@link contestedDigests}.\n *\n * Parsing is deliberately tolerant of everything except the shape it matches\n * on. The format is what cucumber-js `--format json`, cucumber-jvm, behave and\n * SpecFlow emit — an array of features, each `elements[]` (scenarios) with\n * `name`, `tags[] {name}` and `steps[] {result {status}}`, plus the two\n * places those dialects put a failure the steps never see: cucumber-jvm\n * reports @Before/@After hook results in per-element `before[]`/`after[]`\n * arrays (a teardown assertion fails there while every step stays `passed`),\n * and behave carries an element-level `status` that a hook failure flips.\n * Only those fields are contract: unknown fields, tagless elements\n * (backgrounds, hand-written scenarios) and stray entries are skipped without\n * comment. What is NOT tolerated is a file that is not that array at all: a\n * report loam cannot recognize must refuse rather than quietly answer every\n * claim \"not found\".\n */\nimport { DIGEST_TAG_RE, scenarioDigestTag } from \"./gherkin.js\";\nimport type { Answer, Claim } from \"./verify.js\";\n\n/** One report scenario, reduced to what matching needs. */\nexport interface ReportScenario {\n /** `<feature uri or name> › <scenario name>` — where the run can be seen. */\n where: string;\n /** Digests carried as `@loam-digest-…` tags. Usually one; hand-tagging can add more. */\n digests: string[];\n /** Step statuses, lowercased, in order. */\n steps: string[];\n /** Hook statuses from cucumber-jvm's `before[]`/`after[]`, lowercased, in order. */\n hooks: string[];\n /** behave's element-level status, lowercased — absent in the other dialects. */\n status?: string;\n}\n\nexport type ReportRead =\n | { ok: true; scenarios: ReportScenario[] }\n | { ok: false; message: string };\n\n/**\n * Read a parsed cucumber JSON document down to its digest-tagged scenarios.\n * `reportName` is how the caller spelled the file, for the refusal message.\n */\nexport function readCucumberReport(doc: unknown, reportName: string): ReportRead {\n if (!Array.isArray(doc)) {\n return {\n ok: false,\n message:\n `${reportName} is not a cucumber JSON report — expected the top-level array of features ` +\n \"that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits.\",\n };\n }\n const scenarios: ReportScenario[] = [];\n for (const feature of doc) {\n if (!isRecord(feature)) continue;\n const featureName = str(feature[\"uri\"]) ?? str(feature[\"name\"]) ?? \"(unnamed feature)\";\n const elements = feature[\"elements\"];\n if (!Array.isArray(elements)) continue;\n for (const el of elements) {\n if (!isRecord(el)) continue;\n const digests: string[] = [];\n const tags = el[\"tags\"];\n if (Array.isArray(tags)) {\n for (const t of tags) {\n const name = typeof t === \"string\" ? t : isRecord(t) ? str(t[\"name\"]) : undefined;\n const m = name === undefined ? null : DIGEST_TAG_RE.exec(name);\n if (m) digests.push(m[1]!);\n }\n }\n // No digest tag, nothing to match: a background, a hand-written\n // scenario, another tool's output. Invisible here, deliberately.\n if (digests.length === 0) continue;\n const resultStatuses = (raw: unknown): string[] => {\n if (!Array.isArray(raw)) return [];\n return raw.map((s) => {\n const status = isRecord(s) && isRecord(s[\"result\"]) ? str(s[\"result\"][\"status\"]) : undefined;\n return (status ?? \"unknown\").toLowerCase();\n });\n };\n const steps = resultStatuses(el[\"steps\"]);\n // cucumber-jvm's @Before/@After hooks: separate arrays, same result shape.\n const hooks = [...resultStatuses(el[\"before\"]), ...resultStatuses(el[\"after\"])];\n // behave's element-level status — a hook failure flips it while the\n // steps that already ran keep \"passed\".\n const status = str(el[\"status\"])?.toLowerCase();\n scenarios.push({\n where: `${featureName} › ${str(el[\"name\"]) ?? \"(unnamed scenario)\"}`,\n digests,\n steps,\n hooks,\n ...(status === undefined ? {} : { status }),\n });\n }\n }\n return { ok: true, scenarios };\n}\n\n/**\n * The digests that more than one service claims, with the services that claim\n * them — a scenario worded identically in two services' specs.\n *\n * The digest is a hash of the scenario body and of nothing else, so `loam\n * gherkin` stamps the same tag into both repositories and a report from either\n * one matches both claims. A report says which scenario ran; it never says\n * whose suite ran it, and the two repositories never ran each other's tests.\n * loam has no way to tell them apart, so it does not choose: the answer is\n * refused, with the services named, and `--service` is the way to give a report\n * an owner (it narrows the checklist to one repository's claims before the\n * matching starts, so nothing here is contested).\n *\n * Only the SERVICE boundary counts. One service's spec repeating a scenario\n * word for word is genuinely one test — that is the documented meaning of the\n * stamp — and both claims may be answered by the one run.\n */\nexport function contestedDigests(claims: readonly Claim[]): Map<string, string[]> {\n const owners = new Map<string, Set<string>>();\n for (const c of claims) {\n if (c.digest === undefined) continue;\n const seen = owners.get(c.digest) ?? new Set<string>();\n seen.add(c.subject);\n owners.set(c.digest, seen);\n }\n const contested = new Map<string, string[]>();\n for (const [digest, services] of owners) {\n if (services.size > 1) contested.set(digest, [...services].sort());\n }\n return contested;\n}\n\n/**\n * Answer every claim from the report — the runner's half of the record. Meant\n * for `scenario.tested` claims (the only kind carrying a digest); a claim\n * without one is answered `unconfirmed` rather than skipped, because silence\n * must never read as checked.\n *\n * Confirmation is strict on purpose: every matching occurrence — a digest the\n * report holds twice is a re-run, and all of them count — ran at least one\n * step, every step `passed`, every before/after hook `passed`, and the\n * element-level status (when the dialect reports one) is `passed`. One failed\n * occurrence wins as failure, a skipped-only run is \"skipped\" not green, and\n * no match at all is \"not found in report\". A digest two services share is\n * refused before any of that: see {@link contestedDigests}.\n */\nexport function runnerAnswers(\n claims: Claim[],\n scenarios: ReportScenario[],\n reportName: string,\n): Answer[] {\n const contested = contestedDigests(claims);\n return claims.map((c) => {\n const rivals = c.digest === undefined ? undefined : contested.get(c.digest);\n if (rivals !== undefined) {\n return answer(\n c.id,\n \"unconfirmed\",\n [],\n `${scenarioDigestTag(c.digest!)} is worded identically in ${rivals.join(\", \")}, so ${reportName} cannot say whose suite ran it. ` +\n \"Record each service's claims from its own repo with --service.\",\n );\n }\n const runs = c.digest === undefined ? [] : scenarios.filter((s) => s.digests.includes(c.digest!));\n if (runs.length === 0) {\n const tag = c.digest === undefined ? \"a digest tag\" : scenarioDigestTag(c.digest);\n return answer(c.id, \"unconfirmed\", [], `not found in report — no scenario in ${reportName} carries ${tag}`);\n }\n const failing = runs.filter((r) => !passed(r));\n if (failing.length === 0) {\n return answer(c.id, \"confirmed\", runs.map((r) => `${reportName}: ${r.where}`));\n }\n const first = failing[0]!;\n const rerun = runs.length > 1 ? ` — ${failing.length} of ${runs.length} matching runs failed` : \"\";\n return answer(c.id, \"unconfirmed\", [], `${reason(first)} (${reportName}: ${first.where})${rerun}`);\n });\n}\n\nfunction answer(id: string, verdict: Answer[\"verdict\"], evidence: string[], note?: string): Answer {\n return { id, verdict, evidence, ...(note === undefined ? {} : { note }), answered_by: \"runner\" };\n}\n\n/**\n * Green means green: at least one step, every step `passed`, every before/\n * after hook `passed`, and the element-level status (when the dialect carries\n * one) `passed` too. The last two exist because a run the runner itself\n * reports as FAILED — a teardown assertion in an @After hook, exactly the\n * shape the arch axis's outbox checks take — used to read as confirmed here:\n * the steps all passed, and the hook failure lived in fields nobody read.\n */\nfunction passed(r: ReportScenario): boolean {\n return (\n r.steps.length > 0 &&\n r.steps.every((s) => s === \"passed\") &&\n r.hooks.every((h) => h === \"passed\") &&\n (r.status === undefined || r.status === \"passed\")\n );\n}\n\n/** Why a run did not pass, naming the step, hook or element status that says so. */\nfunction reason(r: ReportScenario): string {\n if (r.steps.length === 0) return \"no steps ran\";\n const i = r.steps.findIndex((s) => s !== \"passed\" && s !== \"skipped\");\n if (i >= 0) return `${r.steps[i]} at step ${i + 1}`;\n if (r.steps.some((s) => s === \"skipped\")) return \"skipped\";\n const h = r.hooks.findIndex((s) => s !== \"passed\");\n if (h >= 0) return `${r.hooks[h]} in a before/after hook`;\n return `scenario status '${r.status ?? \"unknown\"}'`;\n}\n\nfunction isRecord(v: unknown): v is Record<string, unknown> {\n return typeof v === \"object\" && v !== null && !Array.isArray(v);\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" && v.trim().length > 0 ? v : undefined;\n}\n"]}
|