@ecoma-io/archkeep 0.27.0 → 0.27.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/package.json +2 -1
- package/src/commands/README.md +7 -4
- package/src/commands/change.mjs +3 -2
- package/src/commands/delta-snapshot.mjs +17 -0
- package/src/commands/delta.mjs +4 -2
- package/src/commands/scenario-evaluation.mjs +97 -3
- package/src/corpus/goldens/adr.json +1 -1
- package/src/corpus/goldens/change.json +2 -2
- package/src/corpus/goldens/check.json +1 -1
- package/src/corpus/goldens/context.json +1 -1
- package/src/corpus/goldens/debt.json +1 -1
- package/src/corpus/goldens/decisions.json +1 -1
- package/src/corpus/goldens/delta.json +2 -2
- package/src/corpus/goldens/diff.json +2 -2
- package/src/corpus/goldens/discover.json +1 -1
- package/src/corpus/goldens/drift.json +1 -1
- package/src/corpus/goldens/evolution.json +1 -1
- package/src/corpus/goldens/explain.json +1 -1
- package/src/corpus/goldens/fitness.json +1 -1
- package/src/corpus/goldens/graph.json +1 -1
- package/src/corpus/goldens/health.json +1 -1
- package/src/corpus/goldens/history.json +1 -1
- package/src/corpus/goldens/impact.json +1 -1
- package/src/corpus/goldens/provenance.json +1 -1
- package/src/corpus/goldens/reconcile.json +1 -1
- package/src/corpus/goldens/report.json +1 -1
- package/src/corpus/goldens/scenario.json +1 -1
- package/src/corpus/goldens/trajectory.json +1 -1
- package/src/corpus/goldens/waivers.json +1 -1
- package/src/governance/reconcile-score.mjs +59 -9
- package/src/report/text.mjs +1 -1
- package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -248
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecoma-io/archkeep",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.1",
|
|
4
4
|
"description": "Architecture authority for human and agentic software development — deterministic, evidence-backed enforcement of declared architecture.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"!src/custom-rules/wasm-fixture.mjs",
|
|
74
74
|
"!src/report/envelope-shape.json",
|
|
75
75
|
"!src/custom-rules/evidence-golden.json",
|
|
76
|
+
"!src/fixtures/",
|
|
76
77
|
"presets/",
|
|
77
78
|
"LICENSE",
|
|
78
79
|
"README.md"
|
package/src/commands/README.md
CHANGED
|
@@ -54,8 +54,10 @@ the resolution order.
|
|
|
54
54
|
introduced/resolved/unchanged/unknown (`./delta-classify.mjs`), with
|
|
55
55
|
unresolvable import sites carried as their own category, never counted as
|
|
56
56
|
violations. Refuses an unreadable, malformed, foreign-schema, or
|
|
57
|
-
incomplete-coverage baseline, a
|
|
58
|
-
|
|
57
|
+
incomplete-coverage baseline, a `command`-carrying report envelope (a graph
|
|
58
|
+
snapshot is `diff`'s input, not delta evidence), a provider mismatch
|
|
59
|
+
(stricter than `diff`'s note — violation identity across two project models
|
|
60
|
+
is not evidence),
|
|
59
61
|
incomplete head coverage, and an Nx workspace with polyglot manifests but no
|
|
60
62
|
plugin registration; a policy-fingerprint change is a loud coverage note, not
|
|
61
63
|
a refusal. A verdict, not a description: a non-waived introduced violation is
|
|
@@ -84,8 +86,9 @@ the resolution order.
|
|
|
84
86
|
pins); an unproven base identity or an undeterminable
|
|
85
87
|
constraint is exit 3, and constraints are left unevaluated over a base the
|
|
86
88
|
run cannot vouch for. Refuses a manifest that fails shape or reference
|
|
87
|
-
validation, an unreadable/malformed/incomplete baseline, a
|
|
88
|
-
mismatch, incomplete head coverage,
|
|
89
|
+
validation, an unreadable/malformed/incomplete baseline, a `command`-carrying
|
|
90
|
+
report envelope in its place, a provider mismatch, incomplete head coverage,
|
|
91
|
+
and the unregistered-plugin graph.
|
|
89
92
|
|
|
90
93
|
- **`impact`** (`./impact.mjs`'s `impactCommand`) — reverse reachability from
|
|
91
94
|
the project graph: given a project name, lists every project that transitively
|
package/src/commands/change.mjs
CHANGED
|
@@ -76,8 +76,9 @@
|
|
|
76
76
|
* exit 3, a `coverage` block naming every file and site the run could not
|
|
77
77
|
* judge — where a parser and `--output` can read it; the rest are throws →
|
|
78
78
|
* exit 3 upstream: a manifest that fails shape or reference validation, an
|
|
79
|
-
* unreadable/malformed/foreign-schema baseline,
|
|
80
|
-
*
|
|
79
|
+
* unreadable/malformed/foreign-schema baseline, a `command`-carrying report
|
|
80
|
+
* envelope in its place, incomplete baseline coverage, a provider mismatch,
|
|
81
|
+
* an unregistered-plugin graph over polyglot manifests,
|
|
81
82
|
* and a run with no boundary law (constraints and the law fingerprint need
|
|
82
83
|
* one).
|
|
83
84
|
*
|
|
@@ -320,6 +320,10 @@ export function readEvidenceSnapshot(path, io = {}) {
|
|
|
320
320
|
*
|
|
321
321
|
* Refusals, each loud:
|
|
322
322
|
* - unreadable/malformed JSON — named with the path and the parse error;
|
|
323
|
+
* - a `command` field — that marker belongs to a report envelope (the graph
|
|
324
|
+
* family), so the document is not delta evidence at all; the family is
|
|
325
|
+
* decided before the schemaVersion refusals, whose "newer version" advice
|
|
326
|
+
* would be false for a file this binary itself wrote;
|
|
323
327
|
* - a `schemaVersion` that is not the integer this format uses — a FUTURE
|
|
324
328
|
* version refuses too: a reader that half-understood a newer format would
|
|
325
329
|
* classify over evidence it misread;
|
|
@@ -360,6 +364,19 @@ export function parseEvidenceSnapshot(text, path) {
|
|
|
360
364
|
);
|
|
361
365
|
}
|
|
362
366
|
|
|
367
|
+
// A `command` field marks a report envelope — the graph family's document,
|
|
368
|
+
// not delta evidence. The family decides before the schemaVersion refusals
|
|
369
|
+
// below: that number is the OTHER format's version, so its "newer version;
|
|
370
|
+
// upgrade" advice would be false for a file this binary itself wrote (#810).
|
|
371
|
+
if (typeof parsed.command === "string") {
|
|
372
|
+
const pointer = parsed.command === "graph" ? " For graph snapshots use 'diff <baseline>'." : "";
|
|
373
|
+
throw new Error(
|
|
374
|
+
`archkeep: the evidence snapshot '${path}' has a 'command' field — it is not a delta ` +
|
|
375
|
+
`evidence snapshot, it is a '${parsed.command}' envelope. delta requires an evidence ` +
|
|
376
|
+
`snapshot (from 'delta --capture').${pointer}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
363
380
|
const problems = [];
|
|
364
381
|
if (!Number.isInteger(parsed.schemaVersion)) {
|
|
365
382
|
problems.push(
|
package/src/commands/delta.mjs
CHANGED
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
* status "no-verdict", exit 3, a `coverage` block naming every file and site
|
|
30
30
|
* the run could not judge — where a parser and `--output` can read it; the
|
|
31
31
|
* rest are throws, exit 3 upstream:
|
|
32
|
-
* - a baseline that cannot be read, parsed, or holds a foreign schemaVersion
|
|
33
|
-
*
|
|
32
|
+
* - a baseline that cannot be read, parsed, or holds a foreign schemaVersion,
|
|
33
|
+
* or a report envelope — a document carrying a `command` field (a graph
|
|
34
|
+
* snapshot is `diff`'s input), refused as the wrong family before any
|
|
35
|
+
* schemaVersion reading (`./delta-snapshot.mjs`'s loader owns those);
|
|
34
36
|
* - a provider mismatch between baseline and this run (`providerMismatch`) —
|
|
35
37
|
* a THROW here where `diff` settles for a note, because violation IDENTITY
|
|
36
38
|
* across two different project models is not trustworthy: the same tree
|
|
@@ -152,11 +152,13 @@ function cloneGraph(graph) {
|
|
|
152
152
|
*
|
|
153
153
|
* @param {object} graph The base graph to apply changes to.
|
|
154
154
|
* @param {DependencyChange[]} changes The hypothetical changes.
|
|
155
|
-
* @returns {{graph: object, applied: string[],
|
|
155
|
+
* @returns {{graph: object, applied: string[],
|
|
156
|
+
* mutations: {type: string, source: string, target: string}[], refused: string[]}}
|
|
156
157
|
*/
|
|
157
158
|
function applyChanges(graph, changes) {
|
|
158
159
|
const cloned = cloneGraph(graph);
|
|
159
160
|
const applied = [];
|
|
161
|
+
const mutations = [];
|
|
160
162
|
const refused = [];
|
|
161
163
|
|
|
162
164
|
for (const change of changes) {
|
|
@@ -203,6 +205,7 @@ function applyChanges(graph, changes) {
|
|
|
203
205
|
source: change.source,
|
|
204
206
|
});
|
|
205
207
|
applied.push(`added dependency: ${change.source} → ${change.target} (${change.edgeType})`);
|
|
208
|
+
mutations.push({ type: "dependency_added", source: change.source, target: change.target });
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
if (change.type === "dependency_removed") {
|
|
@@ -238,6 +241,7 @@ function applyChanges(graph, changes) {
|
|
|
238
241
|
existing.splice(idx, 1);
|
|
239
242
|
const typeLabel = change.edgeType ? ` (${change.edgeType})` : "";
|
|
240
243
|
applied.push(`removed dependency: ${change.source} → ${change.target}${typeLabel}`);
|
|
244
|
+
mutations.push({ type: "dependency_removed", source: change.source, target: change.target });
|
|
241
245
|
}
|
|
242
246
|
}
|
|
243
247
|
// Clean up empty dependency arrays
|
|
@@ -247,7 +251,7 @@ function applyChanges(graph, changes) {
|
|
|
247
251
|
}
|
|
248
252
|
}
|
|
249
253
|
|
|
250
|
-
return { graph: cloned, applied, refused };
|
|
254
|
+
return { graph: cloned, applied, mutations, refused };
|
|
251
255
|
}
|
|
252
256
|
|
|
253
257
|
/**
|
|
@@ -410,6 +414,50 @@ function resolveBaseRevision(root, userBase) {
|
|
|
410
414
|
};
|
|
411
415
|
}
|
|
412
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Merges constraint-impact rows for the named project's own changed edges
|
|
419
|
+
* into one side's rows (#809), preserving the row shape the primitive
|
|
420
|
+
* returns — no new fields, no second judgment.
|
|
421
|
+
*
|
|
422
|
+
* One row per project: when the side already carries a row for the project,
|
|
423
|
+
* the edge, constraint-row and violation lists union without duplicates
|
|
424
|
+
* (constraint rows match by identity — they are the config's own row
|
|
425
|
+
* objects; violations by value, since `judgeEdge` builds fresh objects);
|
|
426
|
+
* otherwise the row is appended. Appending keeps every pre-existing row
|
|
427
|
+
* byte-identical and the order deterministic, because it follows the
|
|
428
|
+
* input changes' order.
|
|
429
|
+
*
|
|
430
|
+
* @param {{project: string, edges: object[], constraintRows: object[],
|
|
431
|
+
* violations: object[]}[]} rows The side's constraint impact, mutated in place.
|
|
432
|
+
* @param {{project: string, edges: object[], constraintRows: object[],
|
|
433
|
+
* violations: object[]}[]} merged Rows computed for the changed edge.
|
|
434
|
+
*/
|
|
435
|
+
function mergeConstraintImpact(rows, merged) {
|
|
436
|
+
for (const row of merged) {
|
|
437
|
+
const existing = rows.find((r) => r.project === row.project);
|
|
438
|
+
if (!existing) {
|
|
439
|
+
rows.push(row);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
for (const edge of row.edges) {
|
|
443
|
+
if (!existing.edges.some((e) => e.target === edge.target && e.type === edge.type)) {
|
|
444
|
+
existing.edges.push(edge);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (const constraintRow of row.constraintRows) {
|
|
448
|
+
if (!existing.constraintRows.includes(constraintRow)) {
|
|
449
|
+
existing.constraintRows.push(constraintRow);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
for (const violation of row.violations) {
|
|
453
|
+
const identity = JSON.stringify(violation);
|
|
454
|
+
if (!existing.violations.some((v) => JSON.stringify(v) === identity)) {
|
|
455
|
+
existing.violations.push(violation);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
413
461
|
/**
|
|
414
462
|
* Evaluates a scenario against the current workspace.
|
|
415
463
|
*
|
|
@@ -452,7 +500,12 @@ export function evaluateScenario(
|
|
|
452
500
|
}
|
|
453
501
|
|
|
454
502
|
// Step 3: Apply scenario changes to the graph
|
|
455
|
-
const {
|
|
503
|
+
const {
|
|
504
|
+
graph: scenarioGraph,
|
|
505
|
+
applied,
|
|
506
|
+
mutations,
|
|
507
|
+
refused,
|
|
508
|
+
} = applyChanges(graph, scenarioInput.changes);
|
|
456
509
|
|
|
457
510
|
// Step 4: Compute scenario impact
|
|
458
511
|
const scenarioImpact = computeImpact(projectName, scenarioGraph);
|
|
@@ -468,6 +521,47 @@ export function evaluateScenario(
|
|
|
468
521
|
);
|
|
469
522
|
}
|
|
470
523
|
|
|
524
|
+
// Step 4b: the named project's own changed edges (#809).
|
|
525
|
+
//
|
|
526
|
+
// `computeImpactConstraints` judges edges INTO the target from its
|
|
527
|
+
// dependents, so a change whose SOURCE is the named project never enters
|
|
528
|
+
// that frame — an edge pointing out of the target is invisible to it, and
|
|
529
|
+
// the source-named run reported `unchanged` where the target-named run of
|
|
530
|
+
// the same change reported the violation. The rows for the changed edge
|
|
531
|
+
// are computed here by the same primitive — never a second judgment — and
|
|
532
|
+
// merged into the frame the edge actually belongs to: additions into the
|
|
533
|
+
// scenario side (the edge exists only there), removals into the current
|
|
534
|
+
// side (it exists only there).
|
|
535
|
+
if (config && config.depConstraints) {
|
|
536
|
+
for (const mutation of mutations) {
|
|
537
|
+
if (mutation.source !== projectName) continue;
|
|
538
|
+
if (mutation.type === "dependency_added") {
|
|
539
|
+
mergeConstraintImpact(
|
|
540
|
+
scenarioConstraintImpact,
|
|
541
|
+
computeImpactConstraints(
|
|
542
|
+
mutation.target,
|
|
543
|
+
[mutation.source],
|
|
544
|
+
scenarioGraph.nodes,
|
|
545
|
+
scenarioGraph.dependencies,
|
|
546
|
+
config.depConstraints,
|
|
547
|
+
),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (mutation.type === "dependency_removed") {
|
|
551
|
+
mergeConstraintImpact(
|
|
552
|
+
currentConstraintImpact,
|
|
553
|
+
computeImpactConstraints(
|
|
554
|
+
mutation.target,
|
|
555
|
+
[mutation.source],
|
|
556
|
+
graph.nodes,
|
|
557
|
+
graph.dependencies,
|
|
558
|
+
config.depConstraints,
|
|
559
|
+
),
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
471
565
|
// Step 5: Build decision impact for both sides
|
|
472
566
|
const currentDecisionImpact = buildDecisionImpact(root, currentConstraintImpact, config);
|
|
473
567
|
const scenarioDecisionImpact = buildDecisionImpact(root, scenarioConstraintImpact, config);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"tool": {
|
|
4
4
|
"name": "@ecoma-io/archkeep",
|
|
5
|
-
"version": "0.27.
|
|
5
|
+
"version": "0.27.1"
|
|
6
6
|
},
|
|
7
7
|
"command": "change",
|
|
8
8
|
"workspace": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"path": "<fixture-root>/.archkeep-delta.json",
|
|
46
46
|
"tool": {
|
|
47
47
|
"name": "@ecoma-io/archkeep",
|
|
48
|
-
"version": "0.27.
|
|
48
|
+
"version": "0.27.1"
|
|
49
49
|
},
|
|
50
50
|
"provider": "native",
|
|
51
51
|
"provenance": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"tool": {
|
|
4
4
|
"name": "@ecoma-io/archkeep",
|
|
5
|
-
"version": "0.27.
|
|
5
|
+
"version": "0.27.1"
|
|
6
6
|
},
|
|
7
7
|
"command": "delta",
|
|
8
8
|
"workspace": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"path": "<fixture-root>/.archkeep-delta.json",
|
|
32
32
|
"tool": {
|
|
33
33
|
"name": "@ecoma-io/archkeep",
|
|
34
|
-
"version": "0.27.
|
|
34
|
+
"version": "0.27.1"
|
|
35
35
|
},
|
|
36
36
|
"provider": "native",
|
|
37
37
|
"provenance": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"tool": {
|
|
4
4
|
"name": "@ecoma-io/archkeep",
|
|
5
|
-
"version": "0.27.
|
|
5
|
+
"version": "0.27.1"
|
|
6
6
|
},
|
|
7
7
|
"command": "diff",
|
|
8
8
|
"workspace": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"path": "<fixture-root>/.archkeep-graph.json",
|
|
34
34
|
"projects": 3,
|
|
35
35
|
"edges": 2,
|
|
36
|
-
"toolVersion": "0.27.
|
|
36
|
+
"toolVersion": "0.27.1",
|
|
37
37
|
"provenance": {
|
|
38
38
|
"commit": "1fd51709c377d99a6139891e1200291cf08aede9",
|
|
39
39
|
"remote": null,
|
|
@@ -268,8 +268,13 @@ export function scoreEdge(edge, keys, intentForbiddenPairs, tagForbiddenPairs) {
|
|
|
268
268
|
* Boundary `allowed`/`forbidden` rows are scored from the canonical judge's
|
|
269
269
|
* findings (matched exactly by `from`/`to`): a `forbidden` row with an
|
|
270
270
|
* `intentForbiddenEdge` finding is `unexpected`, an `allowed` row with an
|
|
271
|
-
* `intentAllowedMissing` finding is `absent`. Project
|
|
272
|
-
*
|
|
271
|
+
* `intentAllowedMissing` finding is `absent`. Project rows are scored against
|
|
272
|
+
* the observed names, and the two forbidden drift planes are scored from the
|
|
273
|
+
* judge's findings as well — `dependencies.forbidden` through its
|
|
274
|
+
* `dependencyForbidden` findings, `forbiddenTags` through its
|
|
275
|
+
* `tagDependencyForbidden` witnesses attributed via `tagsByProject` — so a
|
|
276
|
+
* row's verdict IS the verdict `check` and `drift` render, including the
|
|
277
|
+
* transitive closure a direct-edge re-derivation would read as "match". A
|
|
273
278
|
* `dependencies.allowed` row is an allowlist entry, not an existence claim —
|
|
274
279
|
* its absence in the graph is not divergence, so it scores `match` either
|
|
275
280
|
* way (the divergent direction is the observed edge outside the list, scored
|
|
@@ -285,7 +290,29 @@ export function scoreIntentRows(intent, judgeVerdict, observed, tagsByProject) {
|
|
|
285
290
|
// used by its own test
|
|
286
291
|
const rows = [];
|
|
287
292
|
const observedNames = new Set(observed.projects.map((p) => p.name));
|
|
288
|
-
|
|
293
|
+
// The judge has already judged every forbidden plane on the any-path
|
|
294
|
+
// closure (`../architecture-intent/judge.mjs` emits `dependencyForbidden`
|
|
295
|
+
// and `tagDependencyForbidden` for direct AND transitive paths, as concrete
|
|
296
|
+
// `source`/`target` project names with `boundaryFrom: null`). These two
|
|
297
|
+
// projections score the drift rows from those findings — projecting the
|
|
298
|
+
// canonical verdict, never re-deriving reachability here: a re-walk of the
|
|
299
|
+
// direct-edge list would score a transitive violation the judge reported
|
|
300
|
+
// as a row "match", the divergence `check` and `drift` do report. The
|
|
301
|
+
// judge's pairless findings (`intentUnknownProject`, `intentUnknownTag`)
|
|
302
|
+
// carry no witness pair; the rows they belong to are scored below from the
|
|
303
|
+
// observed names and tags themselves — the same existence predicate the
|
|
304
|
+
// judge used to emit them: a row whose endpoint can never resolve is
|
|
305
|
+
// unknown/unverifiable, never a silent "match".
|
|
306
|
+
const dependencyForbiddenPairs = new Set();
|
|
307
|
+
const tagForbiddenWitnesses = [];
|
|
308
|
+
for (const finding of judgeVerdict.findings) {
|
|
309
|
+
if (finding.source === null || finding.target === null) continue;
|
|
310
|
+
if (finding.rule === "dependencyForbidden") {
|
|
311
|
+
dependencyForbiddenPairs.add(`${finding.source} → ${finding.target}`);
|
|
312
|
+
} else if (finding.rule === "tagDependencyForbidden") {
|
|
313
|
+
tagForbiddenWitnesses.push([finding.source, finding.target]);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
289
316
|
|
|
290
317
|
const boundaryFinding = new Map();
|
|
291
318
|
for (const finding of judgeVerdict.findings) {
|
|
@@ -341,22 +368,45 @@ export function scoreIntentRows(intent, judgeVerdict, observed, tagsByProject) {
|
|
|
341
368
|
}
|
|
342
369
|
for (const forbidden of dependencies.forbidden ?? []) {
|
|
343
370
|
const key = `${forbidden.source} → ${forbidden.target}`;
|
|
371
|
+
// A row naming a project the observed architecture does not have can
|
|
372
|
+
// never fire — the judge reports it as `intentUnknownProject` with no
|
|
373
|
+
// witness pair, so the projection above stays empty for it. Reading
|
|
374
|
+
// that as "match — the ban holds" would be the silent direction; score
|
|
375
|
+
// it unknown, mirroring the boundary plane (`reconcileScores`).
|
|
376
|
+
if (!observedNames.has(forbidden.source) || !observedNames.has(forbidden.target)) {
|
|
377
|
+
row("edge", key, "unknown", "intentUnknownProject", "forbidden", key);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const violated = dependencyForbiddenPairs.has(key);
|
|
344
381
|
row(
|
|
345
382
|
"edge",
|
|
346
383
|
key,
|
|
347
|
-
|
|
348
|
-
|
|
384
|
+
violated ? "unexpected" : "match",
|
|
385
|
+
violated ? "dependencyForbidden" : "match",
|
|
349
386
|
"forbidden",
|
|
350
387
|
key,
|
|
351
388
|
);
|
|
352
389
|
}
|
|
353
390
|
|
|
391
|
+
// Every tag any observed project carries — the existence side of a
|
|
392
|
+
// `forbiddenTags` row, the same vocabulary the judge checks when it emits
|
|
393
|
+
// `intentUnknownTag`.
|
|
394
|
+
const allTags = new Set();
|
|
395
|
+
for (const tags of tagsByProject.values()) {
|
|
396
|
+
for (const tag of tags) allTags.add(tag);
|
|
397
|
+
}
|
|
354
398
|
for (const tagRow of intent.forbiddenTags ?? []) {
|
|
355
399
|
const key = `${tagRow.from} → ${tagRow.to}`;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
400
|
+
// A row naming a tag no observed project carries can never fire — the
|
|
401
|
+
// judge reports it as `intentUnknownTag` with no witness pair. Score it
|
|
402
|
+
// unknown, never a silent "match".
|
|
403
|
+
if (!allTags.has(tagRow.from) || !allTags.has(tagRow.to)) {
|
|
404
|
+
row("tag", key, "unknown", "intentUnknownTag", "tag-forbidden", key);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const violated = tagForbiddenWitnesses.some(([source, target]) => {
|
|
408
|
+
const sourceTags = tagsByProject.get(source) ?? [];
|
|
409
|
+
const targetTags = tagsByProject.get(target) ?? [];
|
|
360
410
|
return sourceTags.includes(tagRow.from) && targetTags.includes(tagRow.to);
|
|
361
411
|
});
|
|
362
412
|
row(
|
package/src/report/text.mjs
CHANGED
|
@@ -774,7 +774,7 @@ function formatUntrackedFilesGap(gap) {
|
|
|
774
774
|
`⚠ ${count} project-owned file${count === 1 ? "" : "s"} ${count === 1 ? "is" : "are"} ` +
|
|
775
775
|
`not tracked by git — never read by this run, so no boundary verdict here covers ${them}\n` +
|
|
776
776
|
`${lines.join("\n")}\n` +
|
|
777
|
-
`${DETAIL}git add ${them} so the next run reads ${them}
|
|
777
|
+
`${DETAIL}git add ${them} so the next run reads ${them}`
|
|
778
778
|
);
|
|
779
779
|
}
|
|
780
780
|
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fixture scaffolding for the Wave 3 W8 evolution-lifecycle conformance suite
|
|
3
|
-
* (`../evolution-lifecycle.integration.test.mjs`). This module is the ONE home
|
|
4
|
-
* for the real-git workspace builders that suite uses — a throwaway native Go
|
|
5
|
-
* workspace per case, materialized through real `git`, driven through the real
|
|
6
|
-
* `archkeep evolution` entry point.
|
|
7
|
-
*
|
|
8
|
-
* It deliberately reuses the native-workspace recipe already proven by
|
|
9
|
-
* `../commands/evolution.cli.integration.test.mjs` (an `archkeep.json` model,
|
|
10
|
-
* a `module-boundaries.config.mjs` law, and Go sources) rather than inventing
|
|
11
|
-
* a second convention, and threads the same environment guard (`../process.mjs`).
|
|
12
|
-
*
|
|
13
|
-
* Nothing here decides a verdict. It builds trees and drives the CLI; the
|
|
14
|
-
* assertions live in the suite. Keeping the builders here (and only here) is
|
|
15
|
-
* what the W8 task boundary requires: fixture scaffolding lives in
|
|
16
|
-
* `./fixtures/evolution-lifecycle/`, nowhere else.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { execFileSync } from "node:child_process";
|
|
20
|
-
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
-
import { tmpdir } from "node:os";
|
|
22
|
-
import { join } from "node:path";
|
|
23
|
-
|
|
24
|
-
import { EXIT, runCli } from "../../../cli.mjs";
|
|
25
|
-
import { SPAWN_BUDGET_MS, SPAWN_TEST_BUDGET_MS } from "../../../spawn-budget.mjs";
|
|
26
|
-
import { environmentForTree } from "../../workspace.mjs";
|
|
27
|
-
|
|
28
|
-
export { SPAWN_TEST_BUDGET_MS, EXIT };
|
|
29
|
-
|
|
30
|
-
/** Identity flags keeping every fixture commit independent of the machine. */
|
|
31
|
-
const IDENTITY = ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"];
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Runs git in `cwd` through the same environment guard production uses, with
|
|
35
|
-
* the single-spawn budget on every child so a wedged git fails the test rather
|
|
36
|
-
* than blocking the worker thread forever.
|
|
37
|
-
*/
|
|
38
|
-
export function git(cwd, ...args) {
|
|
39
|
-
// used by its own test
|
|
40
|
-
return execFileSync("git", args, {
|
|
41
|
-
cwd,
|
|
42
|
-
env: environmentForTree(),
|
|
43
|
-
encoding: "utf8",
|
|
44
|
-
timeout: SPAWN_BUDGET_MS,
|
|
45
|
-
killSignal: "SIGKILL",
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Writes `text` to `root/relativePath`, creating parent directories. */
|
|
50
|
-
export function writeIn(root, relativePath, text) {
|
|
51
|
-
// used by its own test
|
|
52
|
-
mkdirSync(join(root, relativePath, ".."), { recursive: true });
|
|
53
|
-
writeFileSync(join(root, relativePath), text);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Stages every change and commits with the fixture identity; returns the SHA. */
|
|
57
|
-
export function commit(root, message) {
|
|
58
|
-
// used by its own test
|
|
59
|
-
git(root, ...IDENTITY, "add", "-A");
|
|
60
|
-
git(root, ...IDENTITY, "commit", "-q", "-m", message);
|
|
61
|
-
return git(root, "rev-parse", "HEAD").trim();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Opens a brand-new throwaway native git workspace (never the repository's own
|
|
66
|
-
* tree). `archkeep.json` declares two Go projects on two layers, exactly the
|
|
67
|
-
* MODEL `../commands/evolution.cli.integration.test.mjs` uses, so a case can
|
|
68
|
-
* lay an edge between them and the native provider draws it.
|
|
69
|
-
*
|
|
70
|
-
* @returns {{root: string}}
|
|
71
|
-
*/
|
|
72
|
-
export function createWorkspace() {
|
|
73
|
-
const root = mkdtempSync(join(tmpdir(), "archkeep-lifecycle-"));
|
|
74
|
-
git(root, "init", "-q", "-b", "main");
|
|
75
|
-
writeIn(root, "archkeep.json", `${MODEL()}\n`);
|
|
76
|
-
writeIn(root, "libs/alpha/go.mod", "module example.com/alpha\n\ngo 1.22\n");
|
|
77
|
-
writeIn(root, "libs/beta/go.mod", "module example.com/beta\n\ngo 1.22\n");
|
|
78
|
-
return { root };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The native workspace model: two Go projects on two layers (alpha is
|
|
83
|
-
* `layer:a`, beta is `layer:b`), the law file exempted from coverage.
|
|
84
|
-
*/
|
|
85
|
-
const MODEL = () =>
|
|
86
|
-
JSON.stringify(
|
|
87
|
-
{
|
|
88
|
-
projects: {
|
|
89
|
-
declared: [
|
|
90
|
-
{ root: "libs/alpha", name: "alpha", tags: ["layer:a"] },
|
|
91
|
-
{ root: "libs/beta", name: "beta", tags: ["layer:b"] },
|
|
92
|
-
],
|
|
93
|
-
},
|
|
94
|
-
coverage: {
|
|
95
|
-
exempt: [{ path: "module-boundaries.config.mjs", reason: "the workspace's own law" }],
|
|
96
|
-
},
|
|
97
|
-
},
|
|
98
|
-
null,
|
|
99
|
-
2,
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
/** The eight options a valid boundary law must carry, per `policyFrom`. */
|
|
103
|
-
const OPTIONS = `export const moduleBoundaryOptions = {
|
|
104
|
-
allow: [],
|
|
105
|
-
buildTargets: ["build"],
|
|
106
|
-
enforceBuildableLibDependency: false,
|
|
107
|
-
allowCircularSelfDependency: false,
|
|
108
|
-
checkDynamicDependenciesExceptions: [],
|
|
109
|
-
ignoredCircularDependencies: [],
|
|
110
|
-
banTransitiveDependencies: false,
|
|
111
|
-
checkNestedExternalImports: false,
|
|
112
|
-
};
|
|
113
|
-
`;
|
|
114
|
-
|
|
115
|
-
export const ALPHA_CLEAN = `package alpha // used by its own test
|
|
116
|
-
|
|
117
|
-
func Name() string { return "alpha" }
|
|
118
|
-
`;
|
|
119
|
-
|
|
120
|
-
export const ALPHA_REACHING = `package alpha // used by its own test
|
|
121
|
-
|
|
122
|
-
import (
|
|
123
|
-
"example.com/beta"
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
func Name() string { return "alpha" + beta.Suffix() }
|
|
127
|
-
`;
|
|
128
|
-
|
|
129
|
-
export const BETA = `package beta // used by its own test
|
|
130
|
-
|
|
131
|
-
func Suffix() string { return "-beta" }
|
|
132
|
-
`;
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Writes a `module-boundaries.config.mjs` law at `root` with the given
|
|
136
|
-
* `depConstraints` rows and optional `fitness` array.
|
|
137
|
-
*
|
|
138
|
-
* @param {string} root
|
|
139
|
-
* @param {{rows?: string, fitness?: string}} [law]
|
|
140
|
-
*/
|
|
141
|
-
export function writeLaw(root, { rows = "", fitness } = {}) {
|
|
142
|
-
// used by its own test
|
|
143
|
-
writeIn(
|
|
144
|
-
root,
|
|
145
|
-
"module-boundaries.config.mjs",
|
|
146
|
-
`export const depConstraints = [\n${rows}\n];\n${OPTIONS}` +
|
|
147
|
-
(fitness === undefined ? "" : `\nexport const fitness = ${fitness};\n`),
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* A single permitted layer rule (a may reach b). The same ONE_ROW the
|
|
153
|
-
* evolution CLI integration fixtures use, so an allowed alpha→beta edge never
|
|
154
|
-
* trips a boundary rule.
|
|
155
|
-
*/
|
|
156
|
-
export const ALLOW_A_TO_B = ` { sourceTag: "layer:a", onlyDependOnLibsWithTags: ["layer:b"] },`; // used by its own test
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Writes `architecture-intent.json` at `root`. `sections` carries the top-level
|
|
160
|
-
* keys directly (`version`, `boundaries`, `allowed`, `forbidden`,
|
|
161
|
-
* `dependencies`, …); `version` defaults to "1".
|
|
162
|
-
*/
|
|
163
|
-
export function writeIntent(root, sections) {
|
|
164
|
-
// used by its own test
|
|
165
|
-
writeIn(root, "architecture-intent.json", `${JSON.stringify(sections, null, 2)}\n`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Writes one ADR record under `docs/adr/`, the shape `adr-registry.mjs` reads.
|
|
170
|
-
* `record` is the frontmatter map (`{id, status, supersedes?, bindings?}`).
|
|
171
|
-
*/
|
|
172
|
-
export function writeAdr(root, filename, record) {
|
|
173
|
-
// used by its own test
|
|
174
|
-
const lines = ["---", `id: ${record.id}`, `status: ${record.status}`];
|
|
175
|
-
if (record.supersedes?.length) {
|
|
176
|
-
lines.push("supersedes:");
|
|
177
|
-
for (const target of record.supersedes) lines.push(` - ${target}`);
|
|
178
|
-
}
|
|
179
|
-
if (record.bindings?.length) {
|
|
180
|
-
lines.push("bindings:");
|
|
181
|
-
for (const binding of record.bindings) lines.push(` - ${binding}`);
|
|
182
|
-
}
|
|
183
|
-
lines.push("---", "", `# ${record.id}`, "");
|
|
184
|
-
writeIn(root, join("docs/adr", filename), `${lines.join("\n")}\n`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Drives the CLI in-process over `cwd`, capturing streams. Returns the exit
|
|
189
|
-
* code and joined `out`/`err`. `runCli` is the real entry point
|
|
190
|
-
* (`../cli.mjs`), never a shell-out to a binary named `archkeep`.
|
|
191
|
-
*/
|
|
192
|
-
export async function runEvolution(cwd, argv) {
|
|
193
|
-
// used by its own test
|
|
194
|
-
const out = [];
|
|
195
|
-
const err = [];
|
|
196
|
-
const exitCode = await runCli(argv, {
|
|
197
|
-
out: (text) => out.push(text),
|
|
198
|
-
err: (text) => err.push(text),
|
|
199
|
-
cwd,
|
|
200
|
-
});
|
|
201
|
-
return { exitCode, out: out.join("\n"), err: err.join("\n") };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Invokes `evolution --base <base> [--head <head>] [--event-out <dir>] [--format json]`.
|
|
206
|
-
*
|
|
207
|
-
* @param {string} base The base revision (full SHA).
|
|
208
|
-
* @param {{head?: string, eventOut?: string, format?: string}} [options]
|
|
209
|
-
*/
|
|
210
|
-
export function evolutionArgs(base, { head, eventOut, format = "json" } = {}) {
|
|
211
|
-
// used by its own test
|
|
212
|
-
const args = ["evolution", "--base", base];
|
|
213
|
-
if (head) args.push("--head", head);
|
|
214
|
-
if (eventOut) args.push("--event-out", eventOut);
|
|
215
|
-
if (format) args.push("--format", format);
|
|
216
|
-
return args;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Parses the `--format json` envelope out of a successful evolution run.
|
|
221
|
-
*/
|
|
222
|
-
export function parseEnvelope(run) {
|
|
223
|
-
// used by its own test
|
|
224
|
-
if (run.exitCode !== EXIT.ok) throw new Error(`evolution exited ${run.exitCode}: ${run.err}`);
|
|
225
|
-
return JSON.parse(run.out);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** The parsed event files in `dir`, in filename order. */
|
|
229
|
-
export function readEvents(dir) {
|
|
230
|
-
return readdirSync(dir)
|
|
231
|
-
.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
|
|
232
|
-
.sort()
|
|
233
|
-
.map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** The event store's file names in `dir`, in filename order. */
|
|
237
|
-
export function eventFiles(dir) {
|
|
238
|
-
// used by its own test
|
|
239
|
-
return readdirSync(dir)
|
|
240
|
-
.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
|
|
241
|
-
.sort();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Removes a throwaway workspace. */
|
|
245
|
-
export function dispose(root) {
|
|
246
|
-
// used by its own test
|
|
247
|
-
rmSync(root, { recursive: true, force: true });
|
|
248
|
-
}
|