@ecoma-io/archkeep 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -3
- package/cli.mjs +167 -5
- package/commands.mjs +2 -0
- package/package.json +1 -1
- package/src/analysis/kotlin.mjs +60 -0
- package/src/analysis/python.mjs +124 -73
- package/src/commands/diff.mjs +15 -7
- package/src/commands/discover.mjs +40 -0
- package/src/commands/explain.mjs +11 -0
- package/src/commands/impact-statement.mjs +308 -0
- package/src/commands/impact.mjs +9 -0
- package/src/commands/rules.mjs +3 -1
- package/src/commands/scenario-evaluation.mjs +515 -0
- package/src/commands/scenario.mjs +184 -0
- package/src/report/evidence.mjs +13 -0
|
@@ -72,6 +72,46 @@ export function buildObserved(commandContext) {
|
|
|
72
72
|
return { projects, edges };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Convert a discovery proposal into an `architecture-intent.json`-compatible
|
|
77
|
+
* object. The conversion preserves the proposal's structural intent:
|
|
78
|
+
*
|
|
79
|
+
* - **Components** (directory groupings of 2+ projects) → `boundaries` entries
|
|
80
|
+
* with `directory:` selectors.
|
|
81
|
+
* - **`noDependency` rules** → `forbidden` entries — cross-component
|
|
82
|
+
* dependencies that should not exist.
|
|
83
|
+
*
|
|
84
|
+
* Confidence markers and evidence are dropped: the user is expected to review
|
|
85
|
+
* the output before using it with `drift` or `reconcile`.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} proposal The proposal from `evaluateDiscovery`.
|
|
88
|
+
* @returns {{version: string, boundaries: Array<{name: string, match: string[]}>, forbidden?: Array<{source: string, target: string}>}}
|
|
89
|
+
*/
|
|
90
|
+
export function proposalToIntent(proposal) {
|
|
91
|
+
const boundaries = (proposal.components?.items ?? []).map((component) => ({
|
|
92
|
+
name: component.name,
|
|
93
|
+
match: [`directory:${component.commonDirectory}`],
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
// `noDependency` rules map to `forbidden` intent rows. The rules array
|
|
97
|
+
// includes both `noDependency` and `boundary` kinds; only the former
|
|
98
|
+
// carries source/target project pairs.
|
|
99
|
+
const forbidden = (proposal.rules?.items ?? [])
|
|
100
|
+
.filter((rule) => rule.kind === "noDependency")
|
|
101
|
+
.map((rule) => ({
|
|
102
|
+
source: rule.source,
|
|
103
|
+
target: rule.target,
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
version: "1",
|
|
108
|
+
// Auto-generated header comment is not possible in strict JSON; the
|
|
109
|
+
// user is expected to review before using with drift/reconcile.
|
|
110
|
+
boundaries,
|
|
111
|
+
...(forbidden.length > 0 ? { forbidden } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
75
115
|
/**
|
|
76
116
|
* Runs the `discover` command: observes the workspace, optionally proposes the
|
|
77
117
|
* candidate architecture over it, and returns the report.
|
package/src/commands/explain.mjs
CHANGED
|
@@ -97,6 +97,7 @@ import {
|
|
|
97
97
|
resolveDecisionRef,
|
|
98
98
|
stripAdrPrefix,
|
|
99
99
|
} from "../governance/adr-registry.mjs";
|
|
100
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
100
101
|
|
|
101
102
|
/**
|
|
102
103
|
* Parses a `file:line:column` site string into its components.
|
|
@@ -354,6 +355,16 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
354
355
|
|
|
355
356
|
const parsed = parseSite(site);
|
|
356
357
|
|
|
358
|
+
// Normalize the site's sourceFile to a workspace-relative path so it
|
|
359
|
+
// matches the analysis record's sourceFile field (contract.md: workspace-relative).
|
|
360
|
+
// Handles: absolute paths, cwd-relative paths, and backslash separators.
|
|
361
|
+
const rawFile = parsed.sourceFile;
|
|
362
|
+
const normalizedFile = isAbsolute(rawFile)
|
|
363
|
+
? relative(root, rawFile)
|
|
364
|
+
: relative(root, resolve(root, rawFile));
|
|
365
|
+
// Normalize backslash separators (Windows paths) to forward slashes.
|
|
366
|
+
parsed.sourceFile = sep === "\\" ? normalizedFile.replaceAll("\\", "/") : normalizedFile;
|
|
367
|
+
|
|
357
368
|
const notAnalyzed = commandContext.analysis.failures
|
|
358
369
|
.filter(isWholeFileFailure)
|
|
359
370
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Impact Statement: a composed, authoritative, deterministic enumeration of
|
|
3
|
+
* every governed entity a change to one project touches — projects, edges,
|
|
4
|
+
* constraints, and recorded Decisions — each tied to the reproducible evidence
|
|
5
|
+
* that supports the claim, and with every gap reported rather than hidden.
|
|
6
|
+
*
|
|
7
|
+
* This is a composition layer: it calls existing deterministic primitives
|
|
8
|
+
* (`computeImpact`, `computeImpactConstraints`, `readAdrContext`,
|
|
9
|
+
* `resolveDecisionRef`) and assembles their outputs into one statement. It
|
|
10
|
+
* does NOT invent evidence, run new analysis, or add a second authority.
|
|
11
|
+
*
|
|
12
|
+
* ## What it composes
|
|
13
|
+
*
|
|
14
|
+
* - **Reverse reachability** — `computeImpact`: direct and transitive
|
|
15
|
+
* dependents of the target project.
|
|
16
|
+
* - **Edge and boundary impact** — `computeImpactConstraints`: which
|
|
17
|
+
* constraint rows govern each dependent's edge and whether it currently
|
|
18
|
+
* violates them.
|
|
19
|
+
* - **Decision impact** — which recorded decisions bind the affected
|
|
20
|
+
* constraint rows, resolved through the ADR registry. A `decisionRef` that
|
|
21
|
+
* does not resolve is reported in `unresolvedDecisionRefs`, never silently
|
|
22
|
+
* dropped.
|
|
23
|
+
* - **Evolution alignment** — the `affected` shape matching
|
|
24
|
+
* `EvolutionEvent.affected` vocabulary: `projects`, `boundaries`,
|
|
25
|
+
* `constraints`, `decisions`.
|
|
26
|
+
*
|
|
27
|
+
* ## Determinism
|
|
28
|
+
*
|
|
29
|
+
* The statement is deterministic: two runs over an unchanged tree produce
|
|
30
|
+
* byte-identical output. Every claim traces to a reproducible evidence source
|
|
31
|
+
* (the graph, the constraint table, the ADR registry).
|
|
32
|
+
*
|
|
33
|
+
* ## Failure states
|
|
34
|
+
*
|
|
35
|
+
* - An unreadable ADR registry: all decision refs are reported as unresolved
|
|
36
|
+
* (listed in `unresolvedDecisionRefs`), never silently evaluated.
|
|
37
|
+
* - An unknown decision ref: reported in `unresolvedDecisionRefs`, never
|
|
38
|
+
* silently dropped.
|
|
39
|
+
*
|
|
40
|
+
* @module
|
|
41
|
+
*/
|
|
42
|
+
import { readAdrContext } from "./adr.mjs";
|
|
43
|
+
import { computeImpactConstraints } from "./edge-constraints.mjs";
|
|
44
|
+
import { computeImpact } from "./impact.mjs";
|
|
45
|
+
import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
|
|
46
|
+
import { isComboDepConstraint } from "../rules/tags.mjs";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} ImpactStatement
|
|
50
|
+
* @property {string} project The target project name.
|
|
51
|
+
* @property {{direct: string[], transitive: string[], dependents: string[]}} impact
|
|
52
|
+
* Reverse reachability: which projects depend on the target.
|
|
53
|
+
* @property {object[]} [constraintImpact] Per-dependent edge constraint
|
|
54
|
+
* analysis. Present only when a boundary config was provided.
|
|
55
|
+
* @property {{decisions: object[], unresolvedDecisionRefs: string[]}} [decisionImpact]
|
|
56
|
+
* Which recorded decisions bind the affected constraint rows. Present only
|
|
57
|
+
* when a boundary config with `depConstraints` was provided.
|
|
58
|
+
* @property {{projects: string[], boundaries: string[], constraints: string[],
|
|
59
|
+
* decisions: string[]}} [evolutionAlignment] The `affected` shape matching
|
|
60
|
+
* `EvolutionEvent.affected` vocabulary.
|
|
61
|
+
* @property {boolean} complete Whether the statement could be fully composed.
|
|
62
|
+
* @property {string[]} notes Caveats about statement completeness.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a decisionRef to its record details.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} ref The decision reference (bare, `adr:`, or `rule:`/`fitness:`-prefixed).
|
|
69
|
+
* @param {Map<string, object>} byId The ADR registry index.
|
|
70
|
+
* @param {Set<string>} knownFitness Declared fitness names.
|
|
71
|
+
* @returns {{resolution: "adr"|"fitness"|"unknown", record?: object}}
|
|
72
|
+
*/
|
|
73
|
+
function resolveDecision(ref, byId, knownFitness) {
|
|
74
|
+
const resolution = resolveDecisionRef(byId, knownFitness, ref);
|
|
75
|
+
if (resolution === "adr") {
|
|
76
|
+
const record = byId.get(stripAdrPrefix(ref));
|
|
77
|
+
return { resolution, record };
|
|
78
|
+
}
|
|
79
|
+
if (resolution === "fitness") {
|
|
80
|
+
// A fitness ref resolves but has no ADR record entry — it's a
|
|
81
|
+
// rule/fitness id, not an ADR. We report the resolution but have
|
|
82
|
+
// no record details for it.
|
|
83
|
+
return { resolution };
|
|
84
|
+
}
|
|
85
|
+
return { resolution };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds the decision impact section: which recorded decisions bind the
|
|
90
|
+
* affected constraint rows.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} root Workspace root path.
|
|
93
|
+
* @param {object[]} constraintImpact Per-dependent constraint analysis.
|
|
94
|
+
* @param {object} config The loaded boundary config (with `depConstraints`).
|
|
95
|
+
* @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
|
|
96
|
+
* null when the ADR registry is unreadable.
|
|
97
|
+
*/
|
|
98
|
+
function buildDecisionImpact(root, constraintImpact, config) {
|
|
99
|
+
// Collect unique decisionRefs ONLY from constraint rows that are actually
|
|
100
|
+
// AFFECTED by the change — rows that govern edges from impacted dependents.
|
|
101
|
+
// A decisionRef in the config is not enough: the decision must be causally
|
|
102
|
+
// bound to a governance entity the change touches.
|
|
103
|
+
const seenRefs = new Set();
|
|
104
|
+
const affectedRefs = [];
|
|
105
|
+
|
|
106
|
+
if (constraintImpact && config && config.depConstraints) {
|
|
107
|
+
// Use identity matching: constraintImpact.constraintRows are the actual
|
|
108
|
+
// config row objects returned by findConstraintsFor — check by reference,
|
|
109
|
+
// not by string label, for exact causal binding.
|
|
110
|
+
const activeRows = new Set(constraintImpact.flatMap((entry) => entry.constraintRows));
|
|
111
|
+
|
|
112
|
+
for (const row of config.depConstraints) {
|
|
113
|
+
if (!row.decisionRef) continue;
|
|
114
|
+
if (activeRows.has(row) && !seenRefs.has(row.decisionRef)) {
|
|
115
|
+
seenRefs.add(row.decisionRef);
|
|
116
|
+
affectedRefs.push(row.decisionRef);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (affectedRefs.length === 0) {
|
|
122
|
+
return { decisions: [], unresolvedDecisionRefs: [] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Try to read the ADR registry — if it fails, all refs are unresolved
|
|
126
|
+
let adrContext;
|
|
127
|
+
try {
|
|
128
|
+
adrContext = readAdrContext(root);
|
|
129
|
+
} catch {
|
|
130
|
+
return {
|
|
131
|
+
decisions: [],
|
|
132
|
+
unresolvedDecisionRefs: [...affectedRefs],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { byId, knownFitness } = adrContext;
|
|
137
|
+
const unresolvedDecisionRefs = [];
|
|
138
|
+
const decisions = [];
|
|
139
|
+
|
|
140
|
+
for (const ref of affectedRefs) {
|
|
141
|
+
const resolved = resolveDecision(ref, byId, knownFitness);
|
|
142
|
+
|
|
143
|
+
if (resolved.resolution === "unknown") {
|
|
144
|
+
unresolvedDecisionRefs.push(ref);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (resolved.resolution === "fitness") {
|
|
149
|
+
// Fitness refs are not ADR records — report them as resolved
|
|
150
|
+
// but with no record-level details
|
|
151
|
+
decisions.push({
|
|
152
|
+
id: ref,
|
|
153
|
+
kind: "fitness",
|
|
154
|
+
resolution: "known",
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ADR record
|
|
160
|
+
const record = resolved.record;
|
|
161
|
+
decisions.push({
|
|
162
|
+
id: record.id,
|
|
163
|
+
kind: "adr",
|
|
164
|
+
status: record.status,
|
|
165
|
+
hasAuthority: hasAuthority(record.status),
|
|
166
|
+
supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
|
|
167
|
+
supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
173
|
+
unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Builds the evolution alignment section: the `affected` shape matching
|
|
179
|
+
* `EvolutionEvent.affected` vocabulary.
|
|
180
|
+
*
|
|
181
|
+
*
|
|
182
|
+
* @param {{direct: string[], transitive: string[], dependents: string[]}} impact
|
|
183
|
+
* @param {object[]} [constraintImpact] Per-dependent constraint rows.
|
|
184
|
+
* @param {string[]} [resolvedDecisions] Decision IDs that bind affected rows.
|
|
185
|
+
* @returns {{projects: string[], boundaries: string[], constraints: string[],
|
|
186
|
+
* decisions: string[]}}
|
|
187
|
+
*/
|
|
188
|
+
function buildEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
|
|
189
|
+
const affectedProjects = [projectName, ...impact.dependents];
|
|
190
|
+
const affectedConstraints = [];
|
|
191
|
+
const affectedBoundaries = [];
|
|
192
|
+
|
|
193
|
+
if (constraintImpact) {
|
|
194
|
+
for (const entry of constraintImpact) {
|
|
195
|
+
// Collect edge identities for each affected boundary
|
|
196
|
+
for (const edge of entry.edges) {
|
|
197
|
+
const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
|
|
198
|
+
if (!affectedBoundaries.includes(edgeId)) {
|
|
199
|
+
affectedBoundaries.push(edgeId);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Collect constraint row labels
|
|
203
|
+
for (const row of entry.constraintRows) {
|
|
204
|
+
const label = isComboDepConstraint(row)
|
|
205
|
+
? `allSourceTags:${row.allSourceTags.join(",")}`
|
|
206
|
+
: `sourceTag:${row.sourceTag}`;
|
|
207
|
+
if (!affectedConstraints.includes(label)) {
|
|
208
|
+
affectedConstraints.push(label);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
projects: [...new Set(affectedProjects)].sort(),
|
|
216
|
+
boundaries: affectedBoundaries.sort(),
|
|
217
|
+
constraints: affectedConstraints.sort(),
|
|
218
|
+
decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Composes the full Impact Statement for a project.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} projectName The target project.
|
|
226
|
+
* @param {object} commandContext The resolved command context (graph, analysis,
|
|
227
|
+
* root, provider, etc.).
|
|
228
|
+
* @param {object|null} [config] The loaded boundary config. When provided,
|
|
229
|
+
* constraint and decision impact are computed.
|
|
230
|
+
* @returns {ImpactStatement}
|
|
231
|
+
* @throws {import("../errors.mjs").UsageError} When the project is not in the graph.
|
|
232
|
+
*/
|
|
233
|
+
export function composeImpactStatement(projectName, commandContext, config = null) {
|
|
234
|
+
const { root, graph } = commandContext;
|
|
235
|
+
|
|
236
|
+
// Step 1: Reverse reachability (existing primitive)
|
|
237
|
+
const impact = computeImpact(projectName, graph);
|
|
238
|
+
|
|
239
|
+
// Step 2: Edge and constraint impact (existing primitive)
|
|
240
|
+
let constraintImpact = null;
|
|
241
|
+
if (config && config.depConstraints) {
|
|
242
|
+
constraintImpact = computeImpactConstraints(
|
|
243
|
+
projectName,
|
|
244
|
+
impact.dependents,
|
|
245
|
+
graph.nodes,
|
|
246
|
+
graph.dependencies,
|
|
247
|
+
config.depConstraints,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Step 3: Decision impact
|
|
252
|
+
let decisionImpact = null;
|
|
253
|
+
if (constraintImpact) {
|
|
254
|
+
decisionImpact = buildDecisionImpact(root, constraintImpact, config);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Step 4: Evolution alignment
|
|
258
|
+
const resolvedDecisions = decisionImpact ? decisionImpact.decisions.map((d) => d.id) : [];
|
|
259
|
+
const evolutionAlignment = buildEvolutionAlignment(
|
|
260
|
+
projectName,
|
|
261
|
+
impact,
|
|
262
|
+
constraintImpact,
|
|
263
|
+
resolvedDecisions,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Step 5: Assemble the statement with coverage notes
|
|
267
|
+
const notes = [];
|
|
268
|
+
|
|
269
|
+
if (config && config.depConstraints) {
|
|
270
|
+
notes.push(
|
|
271
|
+
"constraint impact covers only depConstraints (3 of 15 violation types). " +
|
|
272
|
+
"A project with no violations here may still violate other rules " +
|
|
273
|
+
"that require import-site details. Run `check` for the complete verdict.",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
notes.push(
|
|
278
|
+
"finding and debt impact are not yet evaluated. " +
|
|
279
|
+
"The impact statement covers dependency structure and constraint violations only.",
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const statement = {
|
|
283
|
+
project: impact.project,
|
|
284
|
+
impact: {
|
|
285
|
+
direct: impact.direct,
|
|
286
|
+
transitive: impact.transitive,
|
|
287
|
+
dependents: impact.dependents,
|
|
288
|
+
},
|
|
289
|
+
evolutionAlignment,
|
|
290
|
+
complete: true,
|
|
291
|
+
notes,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
if (constraintImpact) {
|
|
295
|
+
statement.constraintImpact = constraintImpact;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (decisionImpact) {
|
|
299
|
+
statement.decisionImpact = decisionImpact;
|
|
300
|
+
if (decisionImpact.unresolvedDecisionRefs.length > 0) {
|
|
301
|
+
statement.notes.push(
|
|
302
|
+
`unresolved decision references: ${decisionImpact.unresolvedDecisionRefs.join(", ")}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return statement;
|
|
308
|
+
}
|
package/src/commands/impact.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { computeImpactConstraints } from "./edge-constraints.mjs";
|
|
|
41
41
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
42
42
|
import { formatImpactReport } from "../report/impact-text.mjs";
|
|
43
43
|
import { resolveProvenance } from "./provenance.mjs";
|
|
44
|
+
import { composeImpactStatement } from "./impact-statement.mjs";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Computes the impact set: every project that transitively depends on
|
|
@@ -204,6 +205,14 @@ export function impactCommand(projectName, commandContext, config = null) {
|
|
|
204
205
|
config.depConstraints,
|
|
205
206
|
);
|
|
206
207
|
}
|
|
208
|
+
result.impactStatement = composeImpactStatement(projectName, commandContext, config);
|
|
209
|
+
|
|
210
|
+
// Full impact statement: when a boundary config is available, compose the
|
|
211
|
+
// enriched statement that includes decision impact and evolution alignment
|
|
212
|
+
// in addition to the reverse reachability and constraint impact above.
|
|
213
|
+
if (config) {
|
|
214
|
+
result.impactStatement = composeImpactStatement(projectName, commandContext, config);
|
|
215
|
+
}
|
|
207
216
|
|
|
208
217
|
const envelope = jsonEnvelope({
|
|
209
218
|
command: "impact",
|
package/src/commands/rules.mjs
CHANGED
|
@@ -84,7 +84,9 @@ function loadCatalog(catalogPath, cwd) {
|
|
|
84
84
|
|
|
85
85
|
if (!existsSync(resolvedPath)) {
|
|
86
86
|
throw new Error(
|
|
87
|
-
`catalog not found at ${catalogPath} — install @ecoma-io/archkeep-rules
|
|
87
|
+
`catalog not found at ${catalogPath} — install @ecoma-io/archkeep-rules ` +
|
|
88
|
+
`(\`npm install -D @ecoma-io/archkeep-rules\`) or use --catalog to point to a ` +
|
|
89
|
+
`catalog.json file`,
|
|
88
90
|
);
|
|
89
91
|
}
|
|
90
92
|
|