@sdd-method/author-cli 0.10.0 → 0.12.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/dist/lib/behaviour-store/archive.js +13 -0
- package/dist/lib/catalogue/build.js +45 -2
- package/dist/lib/catalogue/builders/adr-governs.js +39 -0
- package/dist/lib/catalogue/builders/adrs.js +11 -0
- package/dist/lib/catalogue/builders/capability-spec.js +1 -0
- package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
- package/dist/lib/catalogue/builders/data-model.js +316 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
- package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
- package/dist/lib/catalogue/builders/features.js +23 -2
- package/dist/lib/catalogue/builders/integration-components.js +49 -5
- package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
- package/dist/lib/catalogue/builders/quality-scores.js +17 -0
- package/dist/lib/catalogue/builders/requirements.js +168 -32
- package/dist/lib/catalogue/canonical-schema.yaml +845 -28
- package/dist/lib/catalogue/manifest-loader.js +13 -0
- package/dist/lib/catalogue/retired-catalogues.js +25 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -2
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/catalogue/schema-loader.js +3 -0
- package/dist/lib/catalogue/staleness-verdicts.js +75 -6
- package/dist/lib/intents/migrate.js +35 -9
- package/dist/lib/metamodel/tql-subset-parser.js +35 -2
- package/dist/lib/structure/catalogue.js +183 -0
- package/dist/lib/structure/compose.js +174 -0
- package/dist/lib/structure/effects.js +36 -0
- package/dist/lib/structure/model.js +212 -0
- package/dist/lib/structure/render.js +242 -0
- package/dist/lib/structure/view.js +301 -0
- package/dist/lib/validate/agreement-evidence.js +216 -0
- package/dist/lib/validate/behaviour-store.js +516 -28
- package/dist/lib/validate/dispatch.js +21 -0
- package/dist/lib/validate/feature-spec-pointers.js +175 -0
- package/dist/lib/validate/journey-impact.js +193 -0
- package/dist/lib/validate/structural-model.js +265 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { checkArchetypeCitations } from "./archetype-citations.js";
|
|
2
2
|
import { checkBehaviourStore, checkBehaviourStoreOpenspec } from "./behaviour-store.js";
|
|
3
3
|
import { checkBundleCoherence } from "./bundle-coherence.js";
|
|
4
|
+
import { checkFeatureSpecPointers } from "./feature-spec-pointers.js";
|
|
4
5
|
import { checkIntents } from "./intents.js";
|
|
6
|
+
import { checkJourneyImpact } from "./journey-impact.js";
|
|
7
|
+
import { checkStructuralModel } from "./structural-model.js";
|
|
5
8
|
import { checkYamlParse } from "./yaml-parse.js";
|
|
6
9
|
// The behaviour store (M-ADR 0221-0223) can sit in an SDD of any profile, so every profile carries
|
|
7
10
|
// its two checks; without a store both report [N/A], an empty population, not a pass.
|
|
@@ -28,6 +31,24 @@ const BEHAVIOUR_STORE_VALIDATORS = [
|
|
|
28
31
|
source: { kind: "native", run: async (ctx) => checkIntents(ctx.targetRepo) },
|
|
29
32
|
build: () => ({ status: "run", args: [] }),
|
|
30
33
|
},
|
|
34
|
+
// Once behaviour is in the living specs, the feature spec points at it (M-ADR 0221 §4.3). Advisory; [N/A] without a store.
|
|
35
|
+
{
|
|
36
|
+
name: "feature-spec-pointers",
|
|
37
|
+
source: { kind: "native", run: async (ctx) => checkFeatureSpecPointers(ctx.targetRepo) },
|
|
38
|
+
build: () => ({ status: "run", args: [] }),
|
|
39
|
+
},
|
|
40
|
+
// The journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5, Proposed). Report only; [N/A] without journeys.
|
|
41
|
+
{
|
|
42
|
+
name: "journey-impact",
|
|
43
|
+
source: { kind: "native", run: async (ctx) => checkJourneyImpact(ctx.targetRepo) },
|
|
44
|
+
build: () => ({ status: "run", args: [] }),
|
|
45
|
+
},
|
|
46
|
+
// The structural model and each open change's structural artifact (M-ADR 0230, Proposed). Report only; [N/A] without a model.
|
|
47
|
+
{
|
|
48
|
+
name: "structural-model",
|
|
49
|
+
source: { kind: "native", run: async (ctx) => checkStructuralModel(ctx.targetRepo) },
|
|
50
|
+
build: () => ({ status: "run", args: [] }),
|
|
51
|
+
},
|
|
31
52
|
];
|
|
32
53
|
// Validators that accept --app-root / --repo-root flags work fine from the
|
|
33
54
|
// bundled location because they override their internal ROOT_DIR via the
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// feature-spec-pointers: once behaviour is in the living specs, the feature spec points at it and no longer states
|
|
2
|
+
// it (M-ADR 0221 §4.3). Advisory: every finding is a [WARN] and the check exits 0.
|
|
3
|
+
//
|
|
4
|
+
// The population is the store's, never the feature specs': each living spec (openspec/specs/<feature-id>/) should
|
|
5
|
+
// be pointed at by a feature spec, one naming `openspec/specs/<feature-id>/`. A living spec no feature spec points
|
|
6
|
+
// at is reported, whatever its feature spec says, because the feature spec that should point is the one not yet
|
|
7
|
+
// converted.
|
|
8
|
+
//
|
|
9
|
+
// Each feature spec that points at the store is then read whole, not one heading of it, since behaviour is
|
|
10
|
+
// restated in stories, API, Events and Security as well as under Behavior. Reported, as shipped behaviour still
|
|
11
|
+
// stated in the feature spec:
|
|
12
|
+
// criteria list lines of an **Acceptance Criteria** block (a story's criteria are behaviour; its narrative is
|
|
13
|
+
// intent and stays);
|
|
14
|
+
// gherkin Given/When/Then lines anywhere else;
|
|
15
|
+
// sections content lines under a heading that states behaviour: Happy Path, Edge Cases, Error Handling,
|
|
16
|
+
// Business Rules, Validation Rules, Acceptance Criteria.
|
|
17
|
+
// Intent not yet shipped is allowed where it is marked. A heading reading "Not yet shipped or agreed" covers its
|
|
18
|
+
// section, to the next heading of the same or a higher level. A bold line reading so covers what follows it, to the
|
|
19
|
+
// next heading of any level. Open Questions' checkboxes are not
|
|
20
|
+
// criteria. A check cannot tell shipped from unshipped text by reading it, so the marking is what it reads.
|
|
21
|
+
//
|
|
22
|
+
// Feature specs live under docs/domains/<domain>/product/features/ and, in an application SDD, features/. With no
|
|
23
|
+
// living specs the check reports [N/A], an empty population, not a pass.
|
|
24
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
25
|
+
import { join, relative } from "node:path";
|
|
26
|
+
const SPECS_DIR = join("openspec", "specs");
|
|
27
|
+
const BEHAVIOUR_HEADING = /^(happy path|edge cases?|error handling|business rules|validation rules|acceptance criteria)$/;
|
|
28
|
+
const NOT_YET = /not yet (shipped|agreed)/i;
|
|
29
|
+
const CRITERIA_START = /^\*\*Acceptance Criteria:?\*\*/i;
|
|
30
|
+
const GHERKIN = /^\s*(?:[-*]\s+(?:\[[ xX]\]\s+)?)?(?:\*\*)?(Given|When|Then)\b/;
|
|
31
|
+
const LIST_LINE = /^\s*(?:[-*]|\d+\.)\s+\S/;
|
|
32
|
+
/** "## 3. Edge Cases (Revised)" -> "edge cases". */
|
|
33
|
+
function headingKey(h) {
|
|
34
|
+
return h.replace(/^\s*\d+(\.\d+)*\.?\s*/, "").replace(/\((revised|updated)\)\s*$/i, "").trim().toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
/** Line numbers (1-based) of shipped behaviour still stated in a feature spec, by kind. */
|
|
37
|
+
export function scanRestated(text) {
|
|
38
|
+
const out = { criteria: [], gherkin: [], sections: [] };
|
|
39
|
+
const stack = [];
|
|
40
|
+
let inCriteria = false;
|
|
41
|
+
let notYetBlock = false; // after a bold "Not yet shipped or agreed" line, to the next heading
|
|
42
|
+
let fence = false;
|
|
43
|
+
const lines = text.split("\n");
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
const n = i + 1;
|
|
47
|
+
if (/^\s*```/.test(line)) {
|
|
48
|
+
fence = !fence;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const h = fence ? null : /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
52
|
+
if (h) {
|
|
53
|
+
const level = h[1].length;
|
|
54
|
+
while (stack.length && stack[stack.length - 1].level >= level)
|
|
55
|
+
stack.pop();
|
|
56
|
+
stack.push({ level, key: headingKey(h[2]), notYet: NOT_YET.test(h[2]) });
|
|
57
|
+
inCriteria = false;
|
|
58
|
+
notYetBlock = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!fence && /^\*\*[^*]+\*\*:?\s*$/.test(line.trim()) && NOT_YET.test(line)) {
|
|
62
|
+
notYetBlock = true;
|
|
63
|
+
inCriteria = false;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (notYetBlock || stack.some((s) => s.notYet))
|
|
67
|
+
continue;
|
|
68
|
+
if (!fence && CRITERIA_START.test(line.trim())) {
|
|
69
|
+
inCriteria = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!line.trim() || /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line))
|
|
73
|
+
continue; // blank, or a table separator
|
|
74
|
+
if (stack.some((s) => BEHAVIOUR_HEADING.test(s.key))) {
|
|
75
|
+
out.sections.push(n);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (inCriteria && LIST_LINE.test(line)) {
|
|
79
|
+
out.criteria.push(n);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!fence && GHERKIN.test(line) && !stack.some((s) => s.key === "open questions"))
|
|
83
|
+
out.gherkin.push(n);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
async function subdirs(p) {
|
|
88
|
+
try {
|
|
89
|
+
return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function markdownUnder(dir) {
|
|
96
|
+
const out = [];
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
for (const e of entries) {
|
|
105
|
+
const p = join(dir, e.name);
|
|
106
|
+
if (e.isDirectory())
|
|
107
|
+
out.push(...(await markdownUnder(p)));
|
|
108
|
+
else if (e.isFile() && e.name.endsWith(".md") && e.name.toLowerCase() !== "readme.md")
|
|
109
|
+
out.push(p);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
async function featureSpecs(repo) {
|
|
114
|
+
const files = [];
|
|
115
|
+
for (const domain of await subdirs(join(repo, "docs", "domains"))) {
|
|
116
|
+
files.push(...(await markdownUnder(join(repo, "docs", "domains", domain, "product", "features"))));
|
|
117
|
+
}
|
|
118
|
+
files.push(...(await markdownUnder(join(repo, "features"))));
|
|
119
|
+
return files.sort();
|
|
120
|
+
}
|
|
121
|
+
function firstLines(ns) {
|
|
122
|
+
return ns.slice(0, 3).join(", ") + (ns.length > 3 ? ", ..." : "");
|
|
123
|
+
}
|
|
124
|
+
export async function checkFeatureSpecPointers(repo) {
|
|
125
|
+
const living = [];
|
|
126
|
+
for (const id of await subdirs(join(repo, SPECS_DIR))) {
|
|
127
|
+
try {
|
|
128
|
+
await readFile(join(repo, SPECS_DIR, id, "spec.md"));
|
|
129
|
+
living.push(id);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// a directory without a spec is not a living spec
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (living.length === 0)
|
|
136
|
+
return { exitCode: 0, stdout: "[N/A] no living specs (openspec/specs/<feature>/spec.md) in this repository\n" };
|
|
137
|
+
const pointedBy = new Map(living.map((id) => [id, []]));
|
|
138
|
+
const warnings = [];
|
|
139
|
+
let pointing = 0;
|
|
140
|
+
let restating = 0;
|
|
141
|
+
for (const file of await featureSpecs(repo)) {
|
|
142
|
+
const text = await readFile(file, "utf8");
|
|
143
|
+
const rel = relative(repo, file);
|
|
144
|
+
const targets = living.filter((id) => text.includes(`openspec/specs/${id}/`));
|
|
145
|
+
if (targets.length === 0)
|
|
146
|
+
continue;
|
|
147
|
+
pointing++;
|
|
148
|
+
for (const id of targets)
|
|
149
|
+
pointedBy.get(id).push(rel);
|
|
150
|
+
const r = scanRestated(text);
|
|
151
|
+
const parts = [];
|
|
152
|
+
if (r.criteria.length)
|
|
153
|
+
parts.push(`${r.criteria.length} acceptance-criteria line(s) (from line ${firstLines(r.criteria)})`);
|
|
154
|
+
if (r.gherkin.length)
|
|
155
|
+
parts.push(`${r.gherkin.length} Given/When/Then line(s) (from line ${firstLines(r.gherkin)})`);
|
|
156
|
+
if (r.sections.length)
|
|
157
|
+
parts.push(`${r.sections.length} line(s) under a behaviour heading (from line ${firstLines(r.sections)})`);
|
|
158
|
+
if (parts.length) {
|
|
159
|
+
restating++;
|
|
160
|
+
warnings.push(`${rel}: points at the store but still states behaviour outside "Not yet shipped or agreed": ${parts.join("; ")}. Point shipped behaviour at its requirements, and mark intent not yet shipped (M-ADR 0221 §4.3)`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const unpointed = living.filter((id) => pointedBy.get(id).length === 0);
|
|
164
|
+
for (const id of unpointed) {
|
|
165
|
+
warnings.unshift(`${SPECS_DIR}/${id}: no feature spec points at this living spec; its feature spec should name openspec/specs/${id}/ and stop stating what shipped (M-ADR 0221 §4.3)`);
|
|
166
|
+
}
|
|
167
|
+
const head = `feature-spec-pointers: ${living.length} living spec(s), ${living.length - unpointed.length} pointed at by a feature spec; ${pointing} feature spec(s) point at the store, ${restating} still state shipped behaviour\n`;
|
|
168
|
+
if (warnings.length === 0)
|
|
169
|
+
return { exitCode: 0, stdout: `${head}[PASS] feature-spec-pointers: every living spec is pointed at, and no pointing feature spec restates it\n` };
|
|
170
|
+
return {
|
|
171
|
+
exitCode: 0,
|
|
172
|
+
stdout: `${head}${warnings.map((w) => `[WARN] ${w}\n`).join("")}[WARN] feature-spec-pointers: ${warnings.length} finding(s), advisory: reported, not failed\n`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=feature-spec-pointers.js.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// journey-impact: the journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5, Proposed).
|
|
2
|
+
// Report only (M-ADR 0229 §4.5, Proposed): every finding is a [WARN] and the check exits 0. Gate 0 carries this report; nothing
|
|
3
|
+
// blocks on it, and the judge does not fail a change for it.
|
|
4
|
+
//
|
|
5
|
+
// For each open change in the behaviour store (openspec/changes/<change>/, not the archive):
|
|
6
|
+
// listed every journey whose journey_requirement rows name a requirement the change's deltas MODIFY, REMOVE
|
|
7
|
+
// or RENAME (by its former name), and every untraced journey (no journey_requirement row) whose
|
|
8
|
+
// journey_feature rows touch a feature the change has a delta for. The population is the catalogue's,
|
|
9
|
+
// never the proposal's.
|
|
10
|
+
// unnamed a listed journey the proposal's `## Affected Journeys` section does not name.
|
|
11
|
+
// unshown a journey named `altered` with nothing to show it: no delta in the change to a feature of kind journey
|
|
12
|
+
// whose id is the journey's id or ends with it (option B), and no task naming it (option A).
|
|
13
|
+
// A proposal line is `- <journey-id>: altered ...` or `- <journey-id>: unaffected, <reason>`; an unaffected line
|
|
14
|
+
// with no reason is reported too.
|
|
15
|
+
//
|
|
16
|
+
// Also reported: a journey citing a requirement of a feature its journey_feature rows do not name (one record or
|
|
17
|
+
// the other is wrong, M-ADR 0229 §4.4, Proposed); and an SDD with catalogued journeys whose manifest declares no journey master and that names no
|
|
18
|
+
// platform it inherits from (an unresolved root, M-ADR 0229 §4.2, Proposed). A member that names its platform inherits the
|
|
19
|
+
// root's declaration, which is checked at the root.
|
|
20
|
+
//
|
|
21
|
+
// Reads the aggregated catalogue (catalogues-aggregated/*.csv) where this SDD has one, since a root's changes can
|
|
22
|
+
// break journeys its members catalogue and only the aggregate joins the two; otherwise the SDD's own catalogues/.
|
|
23
|
+
// Without journey_requirement.csv the catalogue predates schema 4.4.0 and the check is unresolved, not passed. Without a store or open changes it reports [N/A] for the change
|
|
24
|
+
// part, an empty population.
|
|
25
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { parseDocument } from "yaml";
|
|
28
|
+
import { parseRequirements, renamedFrom } from "../behaviour-store/archive.js";
|
|
29
|
+
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
30
|
+
const CHANGES_DIR = join("openspec", "changes");
|
|
31
|
+
async function readText(p) {
|
|
32
|
+
try {
|
|
33
|
+
return await readFile(p, "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function subdirs(p) {
|
|
40
|
+
try {
|
|
41
|
+
return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function catalogueDir(repo) {
|
|
48
|
+
return (await readText(join(repo, "catalogues-aggregated", "user_journeys.csv"))) !== null ? "catalogues-aggregated" : "catalogues";
|
|
49
|
+
}
|
|
50
|
+
async function csv(repo, name, dir = "catalogues") {
|
|
51
|
+
const text = await readText(join(repo, dir, `${name}.csv`));
|
|
52
|
+
return text === null ? null : parseCatalogueCsv(text).rows;
|
|
53
|
+
}
|
|
54
|
+
/** The `## Affected Journeys` section: journey id -> what the proposal says of it. */
|
|
55
|
+
export function parseAffectedJourneys(proposal) {
|
|
56
|
+
const out = new Map();
|
|
57
|
+
let inSection = false;
|
|
58
|
+
for (const line of proposal.split("\n")) {
|
|
59
|
+
const h = /^##\s+(.+?)\s*$/.exec(line);
|
|
60
|
+
if (h) {
|
|
61
|
+
inSection = /^affected journeys$/i.test(h[1].trim());
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!inSection)
|
|
65
|
+
continue;
|
|
66
|
+
const m = /^\s*[-*]\s+`?([a-z0-9][a-z0-9-]*)`?\s*:\s*(altered|unaffected)?\b[\s,;:.—-]*(.*)$/i.exec(line);
|
|
67
|
+
if (!m)
|
|
68
|
+
continue;
|
|
69
|
+
const verdict = m[2]?.toLowerCase() ?? "other";
|
|
70
|
+
out.set(m[1], { verdict, rest: (m[3] ?? "").trim() });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
export async function checkJourneyImpact(repo) {
|
|
75
|
+
const lines = [];
|
|
76
|
+
const warnings = [];
|
|
77
|
+
const dir = await catalogueDir(repo);
|
|
78
|
+
const journeys = await csv(repo, "user_journeys", dir);
|
|
79
|
+
if (journeys === null || journeys.length === 0) {
|
|
80
|
+
return { exitCode: 0, stdout: "[N/A] journey-impact: no catalogued user journeys (catalogues/ or catalogues-aggregated/ user_journeys.csv) in this repository\n" };
|
|
81
|
+
}
|
|
82
|
+
// The journey master (§4.2).
|
|
83
|
+
const manifest = (await readText(join(repo, ".sdd", "sdd-manifest.yaml"))) ?? "";
|
|
84
|
+
let declared = false;
|
|
85
|
+
let platform = "";
|
|
86
|
+
try {
|
|
87
|
+
const m = parseDocument(manifest).toJS();
|
|
88
|
+
declared = typeof m?.journeys?.master === "string";
|
|
89
|
+
platform = String(m?.platform_sdd_id ?? m?.ecosystem_root ?? "");
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// an unparseable manifest is the manifest check's to report
|
|
93
|
+
}
|
|
94
|
+
if (declared)
|
|
95
|
+
lines.push("journey master: declared in this SDD's manifest");
|
|
96
|
+
else if (platform)
|
|
97
|
+
lines.push(`journey master: not declared here; inherited from platform ${platform}, where it is checked`);
|
|
98
|
+
else
|
|
99
|
+
warnings.push(`.sdd/sdd-manifest.yaml: ${journeys.length} catalogued journey(s) and no journey master (journeys.master: test-file or store), and no platform to inherit one from: unresolved (M-ADR 0229 §4.2, Proposed)`);
|
|
100
|
+
const jr = await csv(repo, "journey_requirement", dir);
|
|
101
|
+
const jf = (await csv(repo, "journey_feature", dir)) ?? [];
|
|
102
|
+
const featureKinds = new Map(((await csv(repo, "features", dir)) ?? []).map((f) => [f["id"], f["kind"] ?? ""]));
|
|
103
|
+
lines.push(`catalogue: ${dir}/`);
|
|
104
|
+
if (jr === null) {
|
|
105
|
+
warnings.push(`${dir}/journey_requirement.csv: missing, so the catalogue predates schema 4.4.0; rebuild it. The journeys a change can break are unresolved`);
|
|
106
|
+
}
|
|
107
|
+
const byRequirement = new Map();
|
|
108
|
+
const traced = new Set();
|
|
109
|
+
for (const r of jr ?? []) {
|
|
110
|
+
traced.add(r["journey_id"]);
|
|
111
|
+
const s = byRequirement.get(r["requirement_id"]) ?? new Set();
|
|
112
|
+
s.add(r["journey_id"]);
|
|
113
|
+
byRequirement.set(r["requirement_id"], s);
|
|
114
|
+
}
|
|
115
|
+
const untracedByFeature = new Map();
|
|
116
|
+
for (const r of jf) {
|
|
117
|
+
if (traced.has(r["journey_id"]))
|
|
118
|
+
continue;
|
|
119
|
+
const s = untracedByFeature.get(r["feature_id"]) ?? new Set();
|
|
120
|
+
s.add(r["journey_id"]);
|
|
121
|
+
untracedByFeature.set(r["feature_id"], s);
|
|
122
|
+
}
|
|
123
|
+
const touched = new Map();
|
|
124
|
+
for (const r of jf) {
|
|
125
|
+
const s = touched.get(r["journey_id"]) ?? new Set();
|
|
126
|
+
s.add(r["feature_id"]);
|
|
127
|
+
touched.set(r["journey_id"], s);
|
|
128
|
+
}
|
|
129
|
+
const mismatched = new Set();
|
|
130
|
+
for (const r of jr ?? []) {
|
|
131
|
+
const feature = r["requirement_id"].split("::")[0];
|
|
132
|
+
const key = `${r["journey_id"]}|${feature}`;
|
|
133
|
+
if (touched.get(r["journey_id"])?.has(feature) || mismatched.has(key))
|
|
134
|
+
continue;
|
|
135
|
+
mismatched.add(key);
|
|
136
|
+
warnings.push(`journey ${r["journey_id"]} cites requirements of ${feature}, which its journey_feature rows do not name: the journey's related features or its tags are wrong (M-ADR 0229 §4.4, Proposed)`);
|
|
137
|
+
}
|
|
138
|
+
const untracedCount = journeys.filter((j) => !traced.has(j["id"])).length;
|
|
139
|
+
lines.push(`journeys: ${journeys.length} catalogued, ${journeys.length - untracedCount} traced to requirements, ${untracedCount} untraced`);
|
|
140
|
+
const changes = (await subdirs(join(repo, CHANGES_DIR))).filter((c) => c !== "archive");
|
|
141
|
+
if (changes.length === 0)
|
|
142
|
+
lines.push("[N/A] changes: no open changes in openspec/changes/");
|
|
143
|
+
for (const change of changes) {
|
|
144
|
+
const dir = join(repo, CHANGES_DIR, change);
|
|
145
|
+
const listed = new Map(); // journey -> why
|
|
146
|
+
for (const feature of await subdirs(join(dir, "specs"))) {
|
|
147
|
+
const text = (await readText(join(dir, "specs", feature, "spec.md"))) ?? "";
|
|
148
|
+
const targets = new Set();
|
|
149
|
+
for (const r of parseRequirements(text))
|
|
150
|
+
if (r.operation === "MODIFIED" || r.operation === "REMOVED")
|
|
151
|
+
targets.add(r.name);
|
|
152
|
+
for (const from of renamedFrom(text))
|
|
153
|
+
targets.add(from);
|
|
154
|
+
for (const name of targets) {
|
|
155
|
+
for (const j of byRequirement.get(`${feature}::${name}`) ?? [])
|
|
156
|
+
if (!listed.has(j))
|
|
157
|
+
listed.set(j, `cites ${feature}::${name}`);
|
|
158
|
+
}
|
|
159
|
+
for (const j of untracedByFeature.get(feature) ?? [])
|
|
160
|
+
if (!listed.has(j))
|
|
161
|
+
listed.set(j, `untraced, touches ${feature}`);
|
|
162
|
+
}
|
|
163
|
+
if (listed.size === 0) {
|
|
164
|
+
lines.push(`${change}: no journey cites what it changes`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const proposal = (await readText(join(dir, "proposal.md"))) ?? "";
|
|
168
|
+
const tasks = (await readText(join(dir, "tasks.md"))) ?? "";
|
|
169
|
+
const named = parseAffectedJourneys(proposal);
|
|
170
|
+
const deltaFeatures = new Set(await subdirs(join(dir, "specs")));
|
|
171
|
+
lines.push(`${change}: ${listed.size} journey(s) listed (${[...listed.entries()].map(([j, why]) => `${j}: ${why}`).join("; ")})`);
|
|
172
|
+
for (const [j, why] of listed) {
|
|
173
|
+
const n = named.get(j);
|
|
174
|
+
if (!n || n.verdict === "other") {
|
|
175
|
+
warnings.push(`${CHANGES_DIR}/${change}/proposal.md: journey ${j} is listed (${why}) and the proposal's ## Affected Journeys does not name it as altered or unaffected`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (n.verdict === "unaffected" && n.rest === "")
|
|
179
|
+
warnings.push(`${CHANGES_DIR}/${change}/proposal.md: journey ${j} is named unaffected without a reason`);
|
|
180
|
+
if (n.verdict === "altered") {
|
|
181
|
+
const journeyDelta = [...deltaFeatures].some((f) => featureKinds.get(f) === "journey" && (f === j || f.endsWith(`-${j}`)));
|
|
182
|
+
const shown = journeyDelta || tasks.includes(j);
|
|
183
|
+
if (!shown)
|
|
184
|
+
warnings.push(`${CHANGES_DIR}/${change}/proposal.md: journey ${j} is named altered, and the change shows nothing for it: no delta to its journey feature and no task naming it`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const body = lines.map((l) => `${l}\n`).join("");
|
|
189
|
+
if (warnings.length === 0)
|
|
190
|
+
return { exitCode: 0, stdout: `journey-impact:\n${body}[PASS] journey-impact: every listed journey is named\n` };
|
|
191
|
+
return { exitCode: 0, stdout: `journey-impact:\n${body}${warnings.map((w) => `[WARN] ${w}\n`).join("")}[WARN] journey-impact: ${warnings.length} finding(s), report only (M-ADR 0229 §4.5, Proposed)\n` };
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=journey-impact.js.map
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// structural-model: the structural model and each open change's structural artifact (M-ADR 0230 §4.3, §4.4, Proposed;
|
|
2
|
+
// docs/method/methods/guides/structural-model-format.md).
|
|
3
|
+
//
|
|
4
|
+
// The model (openspec/structure/<partition>.yaml), as the current view:
|
|
5
|
+
// files valid YAML, schema_version, a partition matching its file name and naming one this SDD has
|
|
6
|
+
// (a platform SDD: its domains and `platform`; an application or integration SDD: its sdd_id)
|
|
7
|
+
// elements a `structure` catalogue, its `structure` columns only, an id held once, required columns,
|
|
8
|
+
// value types and vocabularies (the catalogue metamodel's)
|
|
9
|
+
// references resolved in the view; into a catalogue of another class, against that catalogue as built
|
|
10
|
+
// hierarchies no cycle through a self-reference; natural keys unique; domain invariants naming their entity's own
|
|
11
|
+
// attributes and relationships
|
|
12
|
+
// views each generated view under docs/structure/ missing, stale (rendered from an earlier model) or edited by
|
|
13
|
+
// hand (its body no longer matches the digest in its header)
|
|
14
|
+
// Each open change (openspec/changes/<change>/structure/):
|
|
15
|
+
// artifact where the model exists, a change has partition deltas or none.yaml, never both
|
|
16
|
+
// deltas well formed; ADDED ids new; MODIFIED and REMOVED ids held by the delta's partition; a base
|
|
17
|
+
// revision for every element modified, removed or read, still the current one; a reference into an
|
|
18
|
+
// element another open change adds names that change in depends_on
|
|
19
|
+
// no-op contradicted by a behaviour delta for a feature the model does not hold; a structural delta
|
|
20
|
+
// must add that feature
|
|
21
|
+
// Composition (§4.5; lib/structure/compose.ts), with a change agreed when its agreement record says so:
|
|
22
|
+
// overlap two agreed changes writing one element or adding one id; a change overlapping an agreed one
|
|
23
|
+
// composed each change's view (with its dependency closure) and the intended view (every agreed change)
|
|
24
|
+
// validated on their own; what fails there and not in current is reported against the changes
|
|
25
|
+
// that write the elements it names
|
|
26
|
+
// reads a dependency withdrawn (record withdrawn or superseded, or neither open nor archived), an agreed
|
|
27
|
+
// change depending on one not agreed, a cycle of dependencies
|
|
28
|
+
// Archived changes (§4.6):
|
|
29
|
+
// archived a change archived with structural deltas and no effect record (a raw `openspec archive`): its
|
|
30
|
+
// structure is not applied, so its transition is not shipped
|
|
31
|
+
//
|
|
32
|
+
// Report only while M-ADR 0230 is Proposed (ENFORCED): every finding is a [WARN] and the check exits 0. Without
|
|
33
|
+
// openspec/structure/ and with no change carrying a structure/ directory it reports [N/A].
|
|
34
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { parseDocument } from "yaml";
|
|
37
|
+
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
38
|
+
import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
|
|
39
|
+
import { APPLIED_FILE, elementKey, loadChangeStructure, loadModel, STRUCTURE_DIR } from "../structure/model.js";
|
|
40
|
+
import { agreedOrder, attribute, checkDependencies, checkOverlap, intended, viewAt } from "../structure/compose.js";
|
|
41
|
+
import { describeState, renderViews, VIEW_PATHS, viewState } from "../structure/render.js";
|
|
42
|
+
import { validateDeltas, validateView, viewOf } from "../structure/view.js";
|
|
43
|
+
const VIEW_COUNT = Object.keys(VIEW_PATHS).length;
|
|
44
|
+
/** Flipped when M-ADR 0230 is accepted: findings then FAIL. */
|
|
45
|
+
export const ENFORCED = false;
|
|
46
|
+
const CHANGES_DIR = join("openspec", "changes");
|
|
47
|
+
async function readText(p) {
|
|
48
|
+
try {
|
|
49
|
+
return await readFile(p, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function subdirs(p) {
|
|
56
|
+
try {
|
|
57
|
+
return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The change's agreement record status (docs/agreements/<change>.yaml), or `unrecorded`. */
|
|
64
|
+
async function agreementStatus(repo, change) {
|
|
65
|
+
const text = await readText(join(repo, "docs", "agreements", `${change}.yaml`));
|
|
66
|
+
if (text === null)
|
|
67
|
+
return "unrecorded";
|
|
68
|
+
try {
|
|
69
|
+
const status = String(parseDocument(text).toJS()?.agreement_record?.status ?? "");
|
|
70
|
+
return ["proposed", "agreed", "withdrawn", "superseded"].find((s) => s === status) ?? "unrecorded";
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return "unrecorded";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Names of archived changes: openspec/changes/archive/<YYYY-MM-DD>-<name>. */
|
|
77
|
+
async function archivedNames(repo) {
|
|
78
|
+
return new Set((await subdirs(join(repo, CHANGES_DIR, "archive"))).map((d) => d.replace(/^\d{4}-\d{2}-\d{2}-/, "")));
|
|
79
|
+
}
|
|
80
|
+
function builtLookup(repo) {
|
|
81
|
+
const cache = new Map();
|
|
82
|
+
const fn = ((catalogue, column) => {
|
|
83
|
+
const rows = cache.get(catalogue);
|
|
84
|
+
if (rows === undefined || rows === null)
|
|
85
|
+
return null;
|
|
86
|
+
return new Set(rows.map((r) => r[column] ?? ""));
|
|
87
|
+
});
|
|
88
|
+
fn.load = async (catalogue) => {
|
|
89
|
+
if (cache.has(catalogue))
|
|
90
|
+
return;
|
|
91
|
+
const text = await readText(join(repo, "catalogues", `${catalogue}.csv`));
|
|
92
|
+
cache.set(catalogue, text === null ? null : parseCatalogueCsv(text).rows);
|
|
93
|
+
};
|
|
94
|
+
return fn;
|
|
95
|
+
}
|
|
96
|
+
export async function checkStructuralModel(repo, options = {}) {
|
|
97
|
+
const enforced = options.enforced ?? ENFORCED;
|
|
98
|
+
const problems = [];
|
|
99
|
+
const lines = [];
|
|
100
|
+
const files = await loadModel(repo, problems);
|
|
101
|
+
const changes = (await subdirs(join(repo, CHANGES_DIR))).filter((c) => c !== "archive");
|
|
102
|
+
const structures = [];
|
|
103
|
+
for (const c of changes)
|
|
104
|
+
structures.push(await loadChangeStructure(repo, c, problems));
|
|
105
|
+
if (files === null && !structures.some((s) => s.present)) {
|
|
106
|
+
return { exitCode: 0, stdout: `[N/A] structural-model: no structural model (${STRUCTURE_DIR}/) and no change with structural deltas in this repository\n` };
|
|
107
|
+
}
|
|
108
|
+
const schema = options.schema ?? (await loadCanonicalSchema());
|
|
109
|
+
// Partitions this SDD has.
|
|
110
|
+
const manifest = (await readText(join(repo, ".sdd", "sdd-manifest.yaml"))) ?? "";
|
|
111
|
+
let profile = "";
|
|
112
|
+
let sddId = "";
|
|
113
|
+
try {
|
|
114
|
+
const m = parseDocument(manifest).toJS();
|
|
115
|
+
profile = String(m?.profile ?? "");
|
|
116
|
+
sddId = String(m?.sdd_id ?? "");
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// the manifest check reports an unparseable manifest
|
|
120
|
+
}
|
|
121
|
+
const view = viewOf(files ?? [], problems);
|
|
122
|
+
const built = builtLookup(repo);
|
|
123
|
+
for (const c of schema.catalogues)
|
|
124
|
+
if (c.sourceOfTruth !== "structure")
|
|
125
|
+
await built.load(c.name);
|
|
126
|
+
let allowed = null;
|
|
127
|
+
if (profile === "platform-profile") {
|
|
128
|
+
allowed = new Set(["platform", ...(view.get("domains")?.keys() ?? []), ...(built("domains", "id") ?? [])]);
|
|
129
|
+
}
|
|
130
|
+
else if (sddId !== "") {
|
|
131
|
+
allowed = new Set([sddId]);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
lines.push("partitions: not checked (no profile or sdd_id in .sdd/sdd-manifest.yaml)");
|
|
135
|
+
}
|
|
136
|
+
const checkPartition = (partition, file) => {
|
|
137
|
+
if (allowed !== null && !allowed.has(partition)) {
|
|
138
|
+
problems.push(`${file}: partition ${partition} is not one this SDD has (${profile === "platform-profile" ? "a domain id, or platform" : `its sdd_id, ${sddId}`})`);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (const f of files ?? [])
|
|
142
|
+
checkPartition(f.partition, f.file);
|
|
143
|
+
// The current view.
|
|
144
|
+
const unresolved = new Set();
|
|
145
|
+
const currentProblems = [];
|
|
146
|
+
validateView(schema, view, built, currentProblems, unresolved);
|
|
147
|
+
problems.push(...currentProblems);
|
|
148
|
+
const elementCount = [...view.values()].reduce((n, m) => n + m.size, 0);
|
|
149
|
+
lines.push(files === null ? `model: none (${STRUCTURE_DIR}/ absent)` : `model: ${files.length} partition file(s), ${elementCount} element(s) in ${view.size} catalogue(s)`);
|
|
150
|
+
if (unresolved.size > 0)
|
|
151
|
+
lines.push(`references into ${[...unresolved].sort().join(", ")}: unresolved (no built catalogue)`);
|
|
152
|
+
// The generated views, against a render of the current view. An SDD that has not rendered them yet (no
|
|
153
|
+
// docs/structure/) is adopting the model, which keeps its originals until the review is complete (§6 step 1).
|
|
154
|
+
if (files !== null && (await subdirs(join(repo, "docs"))).includes("structure")) {
|
|
155
|
+
let current = 0;
|
|
156
|
+
for (const [path, rendered] of renderViews(view)) {
|
|
157
|
+
const state = viewState(rendered, await readText(join(repo, path)));
|
|
158
|
+
if (state === "current")
|
|
159
|
+
current++;
|
|
160
|
+
else
|
|
161
|
+
problems.push(describeState(path, state));
|
|
162
|
+
}
|
|
163
|
+
lines.push(`generated views: ${current} of ${VIEW_COUNT} current`);
|
|
164
|
+
}
|
|
165
|
+
else if (files !== null) {
|
|
166
|
+
lines.push("generated views: not rendered (no docs/structure/); `sdd-cli structure render` writes them");
|
|
167
|
+
}
|
|
168
|
+
// Open changes.
|
|
169
|
+
const addedBy = new Map();
|
|
170
|
+
for (const s of structures)
|
|
171
|
+
for (const d of s.deltas)
|
|
172
|
+
for (const e of d.added)
|
|
173
|
+
addedBy.set(elementKey(e.catalogue, e.id), s.change);
|
|
174
|
+
const features = new Set(view.get("features")?.keys() ?? []);
|
|
175
|
+
let withDeltas = 0;
|
|
176
|
+
let noOps = 0;
|
|
177
|
+
let without = 0;
|
|
178
|
+
for (const s of structures) {
|
|
179
|
+
const dir = join(CHANGES_DIR, s.change);
|
|
180
|
+
for (const d of s.deltas)
|
|
181
|
+
checkPartition(d.partition, d.file);
|
|
182
|
+
if (s.deltas.length > 0)
|
|
183
|
+
withDeltas++;
|
|
184
|
+
if (s.noOp)
|
|
185
|
+
noOps++;
|
|
186
|
+
if (!s.present || (s.deltas.length === 0 && !s.noOp)) {
|
|
187
|
+
without++;
|
|
188
|
+
if (files !== null)
|
|
189
|
+
problems.push(`${dir}: no structural artifact; the model exists, so a change states its structural deltas or claims none (structure/none.yaml)`);
|
|
190
|
+
}
|
|
191
|
+
if (s.noOp && s.deltas.length > 0)
|
|
192
|
+
problems.push(`${s.noOp.file}: a no-op claim beside ${s.deltas.length} partition delta(s); state one or the other`);
|
|
193
|
+
const elsewhere = new Map([...addedBy].filter(([, c]) => c !== s.change));
|
|
194
|
+
validateDeltas(schema, view, s.deltas, elsewhere, problems);
|
|
195
|
+
// A behaviour delta for a feature the model does not hold needs that feature added here.
|
|
196
|
+
const adds = new Set(s.deltas.flatMap((d) => d.added.filter((e) => e.catalogue === "features").map((e) => e.id)));
|
|
197
|
+
for (const feature of await subdirs(join(repo, dir, "specs"))) {
|
|
198
|
+
if (features.has(feature) || adds.has(feature))
|
|
199
|
+
continue;
|
|
200
|
+
if (files === null && features.size === 0 && !s.present)
|
|
201
|
+
continue;
|
|
202
|
+
if (s.noOp)
|
|
203
|
+
problems.push(`${s.noOp.file}: claims no structural effect, and the change's behaviour deltas create a living spec for ${feature}, a feature the model does not hold`);
|
|
204
|
+
else
|
|
205
|
+
problems.push(`${dir}: a behaviour delta for ${feature}, a feature the model does not hold, and no structural delta adds features/${feature}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
lines.push(`changes: ${changes.length} open, ${withDeltas} with structural deltas, ${noOps} claiming none, ${without} without the artifact`);
|
|
209
|
+
// Composition (M-ADR 0230 §4.5): the three views and the three checks.
|
|
210
|
+
const open = new Map();
|
|
211
|
+
for (const s of structures)
|
|
212
|
+
open.set(s.change, { structure: s, status: await agreementStatus(repo, s.change) });
|
|
213
|
+
const archived = await archivedNames(repo);
|
|
214
|
+
checkOverlap(open, problems);
|
|
215
|
+
checkDependencies(open, archived, problems);
|
|
216
|
+
const known = new Set(currentProblems);
|
|
217
|
+
const atAgreed = new Set();
|
|
218
|
+
for (const [name, oc] of open) {
|
|
219
|
+
if (oc.structure.deltas.length === 0)
|
|
220
|
+
continue;
|
|
221
|
+
const at = [];
|
|
222
|
+
validateView(schema, viewAt(view, name, open), built, at, new Set());
|
|
223
|
+
for (const p of at) {
|
|
224
|
+
if (known.has(p))
|
|
225
|
+
continue;
|
|
226
|
+
problems.push(`at ${name}: ${p}`);
|
|
227
|
+
if (oc.status === "agreed")
|
|
228
|
+
atAgreed.add(p);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const agreed = agreedOrder(open);
|
|
232
|
+
if (agreed.length > 0) {
|
|
233
|
+
const inIntended = [];
|
|
234
|
+
validateView(schema, intended(view, open), built, inIntended, new Set());
|
|
235
|
+
for (const p of inIntended) {
|
|
236
|
+
if (known.has(p) || atAgreed.has(p))
|
|
237
|
+
continue;
|
|
238
|
+
const by = attribute(p, open, agreed);
|
|
239
|
+
problems.push(`in the intended view, from ${by.length > 0 ? by.join(" with ") : "the agreed changes together"}: ${p}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// An archived change with structural deltas and no effect record: archived by a raw `openspec archive`, so its
|
|
243
|
+
// behaviour moved and its structure did not. Its structural transition is not shipped (§4.6).
|
|
244
|
+
let raw = 0;
|
|
245
|
+
for (const dir of await subdirs(join(repo, CHANGES_DIR, "archive"))) {
|
|
246
|
+
const ap = [];
|
|
247
|
+
const s = await loadChangeStructure(repo, join("archive", dir), ap);
|
|
248
|
+
if (s.deltas.length === 0)
|
|
249
|
+
continue;
|
|
250
|
+
if ((await readText(join(repo, CHANGES_DIR, "archive", dir, "structure", APPLIED_FILE))) !== null)
|
|
251
|
+
continue;
|
|
252
|
+
raw++;
|
|
253
|
+
problems.push(`${join(CHANGES_DIR, "archive", dir)}: archived with structural deltas and no effect record (structure/${APPLIED_FILE}), as a raw \`openspec archive\` leaves it; its structure is not applied, so the transition is not shipped. Run \`sdd-cli change archive ${dir.replace(/^\d{4}-\d{2}-\d{2}-/, "")}\` to apply it, guarded by its base revisions`);
|
|
254
|
+
}
|
|
255
|
+
if (raw > 0)
|
|
256
|
+
lines.push(`archived changes whose structure is not applied: ${raw}`);
|
|
257
|
+
lines.push(`views: current; at each of ${[...open.values()].filter((o) => o.structure.deltas.length > 0).length} change(s) with deltas; intended from ${agreed.length} agreed change(s)`);
|
|
258
|
+
const body = lines.map((l) => `${l}\n`).join("");
|
|
259
|
+
if (problems.length === 0)
|
|
260
|
+
return { exitCode: 0, stdout: `structural-model:\n${body}[PASS] structural-model: ok\n` };
|
|
261
|
+
const tag = enforced ? "FAIL" : "WARN";
|
|
262
|
+
const tail = enforced ? `[FAIL] structural-model: ${problems.length} finding(s)\n` : `[WARN] structural-model: ${problems.length} finding(s), report only (M-ADR 0230, Proposed)\n`;
|
|
263
|
+
return { exitCode: enforced ? 1 : 0, stdout: `structural-model:\n${body}${problems.map((p) => `[${tag}] ${p}\n`).join("")}${tail}` };
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=structural-model.js.map
|