@ecoma-io/archkeep 0.24.1 → 0.26.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/cli.mjs +166 -506
- package/commands.mjs +7 -1
- package/package.json +1 -1
- package/src/architecture-intent/judge.mjs +1 -1
- package/src/architecture-intent/model.mjs +34 -0
- package/src/canonical.mjs +2 -1
- package/src/commands/adr-for-workspace.mjs +63 -0
- package/src/commands/analyze-capability.mjs +13 -0
- package/src/commands/change-intent.mjs +11 -0
- package/src/commands/change.mjs +315 -55
- package/src/commands/check-capability.mjs +13 -0
- package/src/commands/check.mjs +13 -3
- package/src/commands/compare-capability.mjs +20 -0
- package/src/commands/completeness.mjs +12 -0
- package/src/commands/context-command.mjs +31 -0
- package/src/commands/coverage-verdict.mjs +15 -2
- package/src/commands/debt.mjs +30 -0
- package/src/commands/decisions.mjs +59 -10
- package/src/commands/delta-snapshot.mjs +25 -4
- package/src/commands/delta.mjs +301 -67
- package/src/commands/diff.mjs +48 -9
- package/src/commands/discover.mjs +37 -0
- package/src/commands/drift.mjs +45 -0
- package/src/commands/evolution.mjs +22 -2
- package/src/commands/explain-capability.mjs +11 -0
- package/src/commands/explain.mjs +24 -0
- package/src/commands/fitness.mjs +119 -10
- package/src/commands/govern-capability.mjs +16 -0
- package/src/commands/graph.mjs +39 -0
- package/src/commands/health.mjs +34 -0
- package/src/commands/history.mjs +119 -2
- package/src/commands/impact.mjs +23 -0
- package/src/commands/inspect-capability.mjs +16 -0
- package/src/commands/plan-context-command.mjs +35 -0
- package/src/commands/policy.mjs +42 -4
- package/src/commands/provenance-command.mjs +15 -24
- package/src/commands/report.mjs +38 -0
- package/src/commands/rules-capability.mjs +16 -0
- package/src/commands/rules.mjs +3 -1
- package/src/commands/scenario.mjs +23 -0
- package/src/commands/snapshot-meta.mjs +33 -0
- package/src/commands/trajectory.mjs +35 -0
- package/src/commands/waivers.mjs +30 -0
- package/src/corpus/goldens/.gitkeep +0 -0
- package/src/corpus/goldens/adr.json +62 -0
- package/src/corpus/goldens/adr.text +6 -0
- package/src/corpus/goldens/change.json +98 -0
- package/src/corpus/goldens/change.text +6 -0
- package/src/corpus/goldens/check.json +90 -0
- package/src/corpus/goldens/check.sarif +479 -0
- package/src/corpus/goldens/check.text +9 -0
- package/src/corpus/goldens/context.json +47 -0
- package/src/corpus/goldens/context.text +6 -0
- package/src/corpus/goldens/debt.json +59 -0
- package/src/corpus/goldens/debt.text +7 -0
- package/src/corpus/goldens/decisions.json +74 -0
- package/src/corpus/goldens/decisions.text +7 -0
- package/src/corpus/goldens/delta.json +94 -0
- package/src/corpus/goldens/delta.sarif +472 -0
- package/src/corpus/goldens/delta.text +5 -0
- package/src/corpus/goldens/diff.json +58 -0
- package/src/corpus/goldens/diff.text +4 -0
- package/src/corpus/goldens/discover.json +77 -0
- package/src/corpus/goldens/discover.text +10 -0
- package/src/corpus/goldens/drift.json +43 -0
- package/src/corpus/goldens/drift.text +3 -0
- package/src/corpus/goldens/evolution.json +328 -0
- package/src/corpus/goldens/evolution.text +32 -0
- package/src/corpus/goldens/explain.json +60 -0
- package/src/corpus/goldens/explain.text +7 -0
- package/src/corpus/goldens/fitness.json +45 -0
- package/src/corpus/goldens/fitness.text +3 -0
- package/src/corpus/goldens/graph.json +78 -0
- package/src/corpus/goldens/graph.text +10 -0
- package/src/corpus/goldens/health.json +72 -0
- package/src/corpus/goldens/health.text +10 -0
- package/src/corpus/goldens/help.text +104 -0
- package/src/corpus/goldens/history.json +43 -0
- package/src/corpus/goldens/history.text +4 -0
- package/src/corpus/goldens/impact.json +327 -0
- package/src/corpus/goldens/impact.text +10 -0
- package/src/corpus/goldens/provenance.json +453 -0
- package/src/corpus/goldens/provenance.text +14 -0
- package/src/corpus/goldens/reconcile.json +168 -0
- package/src/corpus/goldens/reconcile.text +3 -0
- package/src/corpus/goldens/report.json +168 -0
- package/src/corpus/goldens/report.text +42 -0
- package/src/corpus/goldens/rules verify.json +0 -0
- package/src/corpus/goldens/rules verify.text +0 -0
- package/src/corpus/goldens/scenario.json +441 -0
- package/src/corpus/goldens/scenario.text +28 -0
- package/src/corpus/goldens/trajectory.json +83 -0
- package/src/corpus/goldens/trajectory.text +10 -0
- package/src/corpus/goldens/usage-error.text +105 -0
- package/src/corpus/goldens/waivers.json +40 -0
- package/src/corpus/goldens/waivers.text +1 -0
- package/src/corpus/goldens-lsp/initialize.result.json +15 -0
- package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
- package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
- package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
- package/src/corpus/goldens-lsp/publish-violation.json +22 -0
- package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
- package/src/custom-rules/values.mjs +1 -4
- package/src/go-work.mjs +8 -23
- package/src/governance/evolution-event.mjs +15 -0
- package/src/governance/evolution-store.mjs +59 -31
- package/src/governance/fitness-registry.mjs +14 -1
- package/src/governance/provenance-record.mjs +0 -152
- package/src/governance/verdict.mjs +62 -7
- package/src/intent/intent-manifest.json +22 -16
- package/src/lsp/server.mjs +5 -8
- package/src/lsp/workspace-index.mjs +55 -220
- package/src/options.mjs +10 -0
- package/src/providers/native/discover.mjs +13 -12
- package/src/providers/native/model.mjs +8 -3
- package/src/providers/nx-static.mjs +231 -0
- package/src/report/json.mjs +7 -4
- package/src/report/sarif.mjs +8 -3
- package/src/rules/edge-constraints.mjs +9 -0
- package/src/rules/index.mjs +26 -10
- package/src/rules/messages.mjs +64 -14
- package/src/rules/specifiers.mjs +21 -20
- package/src/tsconfig-paths.mjs +8 -14
- package/src/values.mjs +11 -0
- package/src/verdict.mjs +127 -16
package/src/commands/delta.mjs
CHANGED
|
@@ -105,10 +105,15 @@ import {
|
|
|
105
105
|
serializeEvidenceSnapshot,
|
|
106
106
|
} from "./delta-snapshot.mjs";
|
|
107
107
|
import { computeDiff } from "./diff.mjs";
|
|
108
|
+
import { isAbsolute, resolve } from "node:path";
|
|
108
109
|
import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
|
|
110
|
+
import { eventSnapshotSide } from "./history.mjs";
|
|
109
111
|
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
110
112
|
import { resolveProvenance } from "./provenance.mjs";
|
|
111
|
-
import {
|
|
113
|
+
import { resolveCommandContext } from "./context.mjs";
|
|
114
|
+
import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
|
|
115
|
+
import { describe } from "../values.mjs";
|
|
116
|
+
import { resolvePolicy } from "./policy.mjs";
|
|
112
117
|
|
|
113
118
|
const require = createRequire(import.meta.url);
|
|
114
119
|
/** @type {{name: string, version: string}} */
|
|
@@ -381,11 +386,15 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
|
|
|
381
386
|
* a custom-rule-only introduced finding never reaches the VIOLATION
|
|
382
387
|
* predicate, so a classifications scan would read "accepted" on an exit-1
|
|
383
388
|
* run — the silent direction;
|
|
384
|
-
* -
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
+
* - `ok` status — a clean comparable capture (`[]` classifications), an `ok`
|
|
390
|
+
* capture with a fact class (REPAIR, CHANGE, DRIFT, DECISION_CHANGE — each
|
|
391
|
+
* accepted by the vocabulary `classification` earns), or an `ok` capture
|
|
392
|
+
* holding a WAIVED violation (a waiver is a tracked acceptance — which is
|
|
393
|
+
* exactly what kept the gate `ok`) ⇒ `accepted`;
|
|
394
|
+
* - any OTHER status — a typo, a case- or whitespace-mangled spelling, an
|
|
395
|
+
* absent field — THROWS naming the input, the input latch `verdictFor`
|
|
396
|
+
* holds. There is no fourth status to fold, and folding a stranger to a
|
|
397
|
+
* verdict would be the silent direction.
|
|
389
398
|
*
|
|
390
399
|
* The two refusals that can never reach this mapping — an unjudgeable head
|
|
391
400
|
* and a provider mismatch — THROW before any event exists, so a delta that
|
|
@@ -394,12 +403,162 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
|
|
|
394
403
|
*
|
|
395
404
|
* @param {{status: "ok"|"findings"|"no-verdict"}} input
|
|
396
405
|
* @returns {"accepted"|"rejected"|"no-verdict"}
|
|
406
|
+
* @throws {Error} On any `status` outside the three-verb contract, naming the
|
|
407
|
+
* input.
|
|
397
408
|
*/
|
|
398
409
|
export function deltaDisposition({ status }) {
|
|
399
410
|
// used by its own test
|
|
400
411
|
if (status === "no-verdict") return "no-verdict";
|
|
401
412
|
if (status === "findings") return "rejected";
|
|
402
|
-
return "accepted";
|
|
413
|
+
if (status === "ok") return "accepted";
|
|
414
|
+
throw new Error(
|
|
415
|
+
`deltaDisposition: unknown delta status ${describe(status)} — expected "ok", "findings", ` +
|
|
416
|
+
`or "no-verdict". A status outside the delta verb's own contract has no disposition; ` +
|
|
417
|
+
`guessing one would be the silent direction. This is a bug in archkeep, not a fact about ` +
|
|
418
|
+
`the workspace.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The input latch for `deltaFold`: names the first counted bucket that is not
|
|
424
|
+
* an array, or `null` when every one is. A returned problem becomes the
|
|
425
|
+
* fold's refusal — never a throw, because the refusal is a fact about the run
|
|
426
|
+
* the command reports in-band, the same lane an `unknown` classification
|
|
427
|
+
* takes.
|
|
428
|
+
*
|
|
429
|
+
* Every bucket the COMMAND counts is validated, not only the ones the fold
|
|
430
|
+
* reads first: the custom-rule notes loop, the §1 mapping, `result.summary`
|
|
431
|
+
* and both report faces count the same buckets downstream of the fold, so a
|
|
432
|
+
* bucket that would crash or silently empty one of them is refused while
|
|
433
|
+
* there is still a verdict to withhold.
|
|
434
|
+
*
|
|
435
|
+
* @param {object} classification From `classifyDelta`.
|
|
436
|
+
* @param {{judged: object[], skipped: object[], removed: string[],
|
|
437
|
+
* findings: {introduced: object[], resolved: object[], unchanged: object[],
|
|
438
|
+
* unknown: object[]}}|null} custom
|
|
439
|
+
* @returns {string|null}
|
|
440
|
+
*/
|
|
441
|
+
function deltaFoldInputProblem(classification, custom) {
|
|
442
|
+
const counted = [
|
|
443
|
+
["classification.violations.introduced", classification?.violations?.introduced],
|
|
444
|
+
["classification.violations.resolved", classification?.violations?.resolved],
|
|
445
|
+
["classification.violations.unchanged", classification?.violations?.unchanged],
|
|
446
|
+
["classification.violations.unknown", classification?.violations?.unknown],
|
|
447
|
+
["classification.unresolvable.introduced", classification?.unresolvable?.introduced],
|
|
448
|
+
["classification.unresolvable.resolved", classification?.unresolvable?.resolved],
|
|
449
|
+
["classification.unresolvable.unchanged", classification?.unresolvable?.unchanged],
|
|
450
|
+
["classification.unresolvable.unknown", classification?.unresolvable?.unknown],
|
|
451
|
+
...(custom === null
|
|
452
|
+
? []
|
|
453
|
+
: [
|
|
454
|
+
["custom.judged", custom?.judged],
|
|
455
|
+
["custom.skipped", custom?.skipped],
|
|
456
|
+
["custom.removed", custom?.removed],
|
|
457
|
+
["custom.findings.introduced", custom?.findings?.introduced],
|
|
458
|
+
["custom.findings.resolved", custom?.findings?.resolved],
|
|
459
|
+
["custom.findings.unchanged", custom?.findings?.unchanged],
|
|
460
|
+
["custom.findings.unknown", custom?.findings?.unknown],
|
|
461
|
+
]),
|
|
462
|
+
];
|
|
463
|
+
for (const [name, value] of counted) {
|
|
464
|
+
if (!Array.isArray(value)) {
|
|
465
|
+
return `the delta fold counts "${name}", which is ${describe(value)} — a bucket that is not an array counts as empty in every lane, and an empty delta reads "no change". This is a bug in archkeep, not a fact about the workspace.`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The `delta` exit fold, lifted out of `deltaCommand` as a pure function so
|
|
473
|
+
* the mapping from classification buckets to the verdict is a fact a test can
|
|
474
|
+
* pin (`./change.mjs`'s `reconcileDisposition` is the arrangement's
|
|
475
|
+
* precedent). The lane order is the one `deltaCommand`'s header states:
|
|
476
|
+
* introduced-and-not-waived (violations and custom findings alike) →
|
|
477
|
+
* `findings`/1; else any unknown item → `no-verdict`/3; else `ok`/0.
|
|
478
|
+
*
|
|
479
|
+
* The input latch runs first: a bucket the fold cannot read is refused as a
|
|
480
|
+
* no-verdict whose single reason names it (`refused`), never folded past as a
|
|
481
|
+
* silently-empty count. The literals stay hand-rolled per site by decision —
|
|
482
|
+
* the carrier folds are the pinned baseline INV-2 names, not a table to be
|
|
483
|
+
* converged (`docs/architecture/refactor/AUTHORITY-MAP.md`).
|
|
484
|
+
*
|
|
485
|
+
* Pure and exported for the fold's own tests; `deltaCommand` is its only
|
|
486
|
+
* production caller.
|
|
487
|
+
*
|
|
488
|
+
* @param {object} classification From `classifyDelta`.
|
|
489
|
+
* @param {{judged: object[], skipped: object[], removed: string[],
|
|
490
|
+
* findings: {introduced: object[], resolved: object[], unchanged: object[],
|
|
491
|
+
* unknown: object[]}}|null} custom
|
|
492
|
+
* @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
|
|
493
|
+
* decision: object, introducedWaived: number, refused?: undefined}|
|
|
494
|
+
* {status: "no-verdict", exitCode: 3, decision: object, introducedWaived?: undefined,
|
|
495
|
+
* refused: string}}
|
|
496
|
+
* `introducedWaived` rides every judged lane because `result.summary`
|
|
497
|
+
* reports it whichever lane fired; a refusal carries `refused` and no
|
|
498
|
+
* counts, and is the one return whose `decision.reason` names a malformed
|
|
499
|
+
* input rather than a fact about the trees.
|
|
500
|
+
*/
|
|
501
|
+
export function deltaFold(classification, custom) {
|
|
502
|
+
const problem = deltaFoldInputProblem(classification, custom);
|
|
503
|
+
if (problem !== null) {
|
|
504
|
+
return {
|
|
505
|
+
status: "no-verdict",
|
|
506
|
+
exitCode: 3,
|
|
507
|
+
decision: buildDecision({
|
|
508
|
+
status: "no-verdict",
|
|
509
|
+
coverageComplete: true,
|
|
510
|
+
findings: 0,
|
|
511
|
+
reason: problem,
|
|
512
|
+
}),
|
|
513
|
+
refused: problem,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const { violations, unresolvable } = classification;
|
|
517
|
+
const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
|
|
518
|
+
const introducedNotWaived = violations.introduced.length - introducedWaived;
|
|
519
|
+
// Custom findings have no waiver lane (`./delta-classify.mjs`'s
|
|
520
|
+
// `classifyCustomFindings` argues the by-construction absence), so every
|
|
521
|
+
// introduced one gates.
|
|
522
|
+
const customIntroduced = custom === null ? 0 : custom.findings.introduced.length;
|
|
523
|
+
const customUnknown = custom === null ? 0 : custom.findings.unknown.length;
|
|
524
|
+
const unknownCount = violations.unknown.length + unresolvable.unknown.length + customUnknown;
|
|
525
|
+
|
|
526
|
+
if (introducedNotWaived + customIntroduced > 0) {
|
|
527
|
+
return {
|
|
528
|
+
status: "findings",
|
|
529
|
+
exitCode: 1,
|
|
530
|
+
decision: buildDecision({
|
|
531
|
+
status: "findings",
|
|
532
|
+
coverageComplete: true,
|
|
533
|
+
findings: introducedNotWaived + customIntroduced,
|
|
534
|
+
}),
|
|
535
|
+
introducedWaived,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (unknownCount > 0) {
|
|
539
|
+
return {
|
|
540
|
+
status: "no-verdict",
|
|
541
|
+
exitCode: 3,
|
|
542
|
+
decision: buildDecision({
|
|
543
|
+
status: "no-verdict",
|
|
544
|
+
coverageComplete: true,
|
|
545
|
+
findings: 0,
|
|
546
|
+
reason:
|
|
547
|
+
`${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
|
|
548
|
+
(customUnknown > 0
|
|
549
|
+
? `${customUnknown} of them custom-rule item${customUnknown === 1 ? "" : "s"} — `
|
|
550
|
+
: "") +
|
|
551
|
+
`an item whose identity cannot be stated is never guessed into a bucket`,
|
|
552
|
+
}),
|
|
553
|
+
introducedWaived,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
status: "ok",
|
|
558
|
+
exitCode: 0,
|
|
559
|
+
decision: buildDecision({ status: "ok", coverageComplete: true, findings: 0 }),
|
|
560
|
+
introducedWaived,
|
|
561
|
+
};
|
|
403
562
|
}
|
|
404
563
|
|
|
405
564
|
/** First eight hex characters of a fingerprint, for prose that names one. */
|
|
@@ -448,8 +607,8 @@ const short = (fingerprint) =>
|
|
|
448
607
|
* `loadIntent`; absent intent ⇒ no ids, an in-band note says so), and the
|
|
449
608
|
* custom-rule host's two injectable seams, passed through to
|
|
450
609
|
* `customRulesForDelta`.
|
|
451
|
-
* @returns {Promise<{status: "ok"|"findings"|"no-verdict",
|
|
452
|
-
* coverage: object,
|
|
610
|
+
* @returns {Promise<{status: "ok"|"findings"|"no-verdict",
|
|
611
|
+
* exitCode: 0|1|3, delta?: object, coverage: object,
|
|
453
612
|
* eventWrite?: {id: string, duplicate: boolean}|null,
|
|
454
613
|
* report: {text: string, json: string, sarif?: string}}>} `delta` carries
|
|
455
614
|
* the additive `classifications`/`affected` fields (design §1); `eventWrite`
|
|
@@ -569,16 +728,10 @@ export async function deltaCommand(
|
|
|
569
728
|
);
|
|
570
729
|
}
|
|
571
730
|
if (meta.dirtyBaseline) {
|
|
572
|
-
notes.push(
|
|
573
|
-
"the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
|
|
574
|
-
"claim about the commit it names",
|
|
575
|
-
);
|
|
731
|
+
notes.push(dirtyBaselineNote(false));
|
|
576
732
|
}
|
|
577
733
|
if (meta.dirtyHead) {
|
|
578
|
-
notes.push(
|
|
579
|
-
"this run's working tree is dirty — the head side describes uncommitted state, not the " +
|
|
580
|
-
"commit HEAD names",
|
|
581
|
-
);
|
|
734
|
+
notes.push(dirtyHeadNote());
|
|
582
735
|
}
|
|
583
736
|
|
|
584
737
|
// The event's structural signal: what moved between the two graphs the
|
|
@@ -588,10 +741,11 @@ export async function deltaCommand(
|
|
|
588
741
|
// a second spelling of "changed". The head side is rebuilt by the same
|
|
589
742
|
// `buildProjects`/`buildDependencies` the snapshot stores
|
|
590
743
|
// (`./delta-snapshot.mjs`), which is what makes the two sides comparable.
|
|
591
|
-
const
|
|
744
|
+
const headGraph = {
|
|
592
745
|
projects: buildProjects(graph.nodes),
|
|
593
746
|
dependencies: buildDependencies(graph.dependencies),
|
|
594
|
-
}
|
|
747
|
+
};
|
|
748
|
+
const structuralDiff = computeDiff(baseline.graph, headGraph);
|
|
595
749
|
const structural = {
|
|
596
750
|
projects: {
|
|
597
751
|
added: structuralDiff.addedProjects.map((project) => project.name),
|
|
@@ -618,6 +772,25 @@ export async function deltaCommand(
|
|
|
618
772
|
// same", and a policy change is disclosed, not folded into drift.
|
|
619
773
|
const baseCommit = baseline.provenance?.commit;
|
|
620
774
|
const headCommit = headProvenance?.commit;
|
|
775
|
+
// The event's identity sides are frozen HERE — at the same instant the diff
|
|
776
|
+
// above was computed from these same graph objects — so the identity always
|
|
777
|
+
// describes the graph the diff actually compared. Everything below this line
|
|
778
|
+
// (custom rules, classification, intent, debt) reads the graphs too; the
|
|
779
|
+
// engine's purity is what keeps them honest today, but the WS-I adversarial
|
|
780
|
+
// audit named the seam: identity and diff agreeing must not rest on purity
|
|
781
|
+
// conventions alone when one structural line closes it.
|
|
782
|
+
const eventBase = eventSnapshotSide({
|
|
783
|
+
revision: baseCommit,
|
|
784
|
+
projects: baseline.graph.projects,
|
|
785
|
+
dependencies: baseline.graph.dependencies,
|
|
786
|
+
policyFingerprint: baseline.policyFingerprint,
|
|
787
|
+
});
|
|
788
|
+
const eventHead = eventSnapshotSide({
|
|
789
|
+
revision: headCommit,
|
|
790
|
+
projects: headGraph.projects,
|
|
791
|
+
dependencies: headGraph.dependencies,
|
|
792
|
+
policyFingerprint: headFingerprint,
|
|
793
|
+
});
|
|
621
794
|
const provenanceAdvanced =
|
|
622
795
|
typeof baseCommit === "string" &&
|
|
623
796
|
typeof headCommit === "string" &&
|
|
@@ -676,6 +849,48 @@ export async function deltaCommand(
|
|
|
676
849
|
findings: { introduced: [], resolved: [], unchanged: [], unknown: [] },
|
|
677
850
|
};
|
|
678
851
|
}
|
|
852
|
+
// The exit fold, taken the moment both of its inputs are final: everything
|
|
853
|
+
// below — the custom-rule notes loop, the §1 mapping, `result.summary`, both
|
|
854
|
+
// report faces — counts the same buckets, and a malformed one must be
|
|
855
|
+
// refused HERE, in-band (no-verdict, exit 3, the bucket named), never
|
|
856
|
+
// counted past as a silently-empty bucket. The refusal withholds `delta`
|
|
857
|
+
// and the event exactly the coverage refusal does (#608): a comparison
|
|
858
|
+
// whose counts could not be read has no result to report.
|
|
859
|
+
const fold = deltaFold(classification, custom);
|
|
860
|
+
if (fold.refused !== undefined) {
|
|
861
|
+
const refusalCoverage = {
|
|
862
|
+
complete: true,
|
|
863
|
+
projects: Object.keys(graph.nodes).length,
|
|
864
|
+
analyzedFiles: analysis.analyzed,
|
|
865
|
+
imports: analysis.imports.length,
|
|
866
|
+
notAnalyzed: [],
|
|
867
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
868
|
+
notes: [fold.refused],
|
|
869
|
+
};
|
|
870
|
+
return {
|
|
871
|
+
status: fold.status,
|
|
872
|
+
exitCode: fold.exitCode,
|
|
873
|
+
coverage: refusalCoverage,
|
|
874
|
+
report: {
|
|
875
|
+
text: `delta: no verdict — ${fold.refused}\n`,
|
|
876
|
+
json: renderJson(
|
|
877
|
+
jsonEnvelope({
|
|
878
|
+
command: "delta",
|
|
879
|
+
context: { root, provider, marker, provenance: headProvenance },
|
|
880
|
+
status: fold.status,
|
|
881
|
+
exitCode: fold.exitCode,
|
|
882
|
+
coverage: refusalCoverage,
|
|
883
|
+
// The refusal-withheld payload, stated the way `coverageRefusal`
|
|
884
|
+
// states it: `jsonEnvelope` requires the key, the refusal has no
|
|
885
|
+
// result to report.
|
|
886
|
+
result: undefined,
|
|
887
|
+
decision: fold.decision,
|
|
888
|
+
}),
|
|
889
|
+
),
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
679
894
|
if (custom !== null) {
|
|
680
895
|
for (const skipped of custom.skipped) {
|
|
681
896
|
notes.push(`custom rule "${skipped.name}" was not classified — ${skipped.reason}`);
|
|
@@ -722,48 +937,10 @@ export async function deltaCommand(
|
|
|
722
937
|
codeDrift,
|
|
723
938
|
});
|
|
724
939
|
|
|
940
|
+
// The verdict, from the one fold — `deltaFold` above owns the lane order
|
|
941
|
+
// and the input latch; this destructure is the command's only hand in it.
|
|
725
942
|
const { violations, unresolvable } = classification;
|
|
726
|
-
const
|
|
727
|
-
const introducedNotWaived = violations.introduced.length - introducedWaived;
|
|
728
|
-
// Custom findings have no waiver lane (`./delta-classify.mjs`'s
|
|
729
|
-
// `classifyCustomFindings` argues the by-construction absence), so every
|
|
730
|
-
// introduced one gates.
|
|
731
|
-
const customIntroduced = custom === null ? 0 : custom.findings.introduced.length;
|
|
732
|
-
const customUnknown = custom === null ? 0 : custom.findings.unknown.length;
|
|
733
|
-
const unknownCount = violations.unknown.length + unresolvable.unknown.length + customUnknown;
|
|
734
|
-
|
|
735
|
-
/** @type {"ok"|"findings"|"no-verdict"} */
|
|
736
|
-
let status;
|
|
737
|
-
/** @type {0|1|3} */
|
|
738
|
-
let exitCode;
|
|
739
|
-
let decision;
|
|
740
|
-
if (introducedNotWaived + customIntroduced > 0) {
|
|
741
|
-
status = "findings";
|
|
742
|
-
exitCode = 1;
|
|
743
|
-
decision = buildDecision({
|
|
744
|
-
status,
|
|
745
|
-
coverageComplete: true,
|
|
746
|
-
findings: introducedNotWaived + customIntroduced,
|
|
747
|
-
});
|
|
748
|
-
} else if (unknownCount > 0) {
|
|
749
|
-
status = "no-verdict";
|
|
750
|
-
exitCode = 3;
|
|
751
|
-
decision = buildDecision({
|
|
752
|
-
status,
|
|
753
|
-
coverageComplete: true,
|
|
754
|
-
findings: 0,
|
|
755
|
-
reason:
|
|
756
|
-
`${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
|
|
757
|
-
(customUnknown > 0
|
|
758
|
-
? `${customUnknown} of them custom-rule item${customUnknown === 1 ? "" : "s"} — `
|
|
759
|
-
: "") +
|
|
760
|
-
`an item whose identity cannot be stated is never guessed into a bucket`,
|
|
761
|
-
});
|
|
762
|
-
} else {
|
|
763
|
-
status = "ok";
|
|
764
|
-
exitCode = 0;
|
|
765
|
-
decision = buildDecision({ status, coverageComplete: true, findings: 0 });
|
|
766
|
-
}
|
|
943
|
+
const { status, exitCode, decision, introducedWaived } = fold;
|
|
767
944
|
|
|
768
945
|
const coverage = {
|
|
769
946
|
complete: true,
|
|
@@ -878,13 +1055,18 @@ export async function deltaCommand(
|
|
|
878
1055
|
schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
879
1056
|
kind: "transition",
|
|
880
1057
|
source: "delta",
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
head:
|
|
1058
|
+
// Both sides were frozen through the ONE identity spelling
|
|
1059
|
+
// (`eventSnapshotSide`, ./history.mjs) at the diff site above: a
|
|
1060
|
+
// revision when one is known, plus the snapshot identity of the graph
|
|
1061
|
+
// the diff compared — never the baseline's storage path, which is
|
|
1062
|
+
// machine-local and must not make the identity a per-machine property.
|
|
1063
|
+
base: eventBase,
|
|
1064
|
+
head: eventHead,
|
|
1065
|
+
// The evidence ref is the baseline file this run actually compared
|
|
1066
|
+
// against — a pointer into the evidence, never a graph, and disclosed
|
|
1067
|
+
// OUTSIDE the identity: the tuple above names the state itself, so a
|
|
1068
|
+
// relocated baseline is still the same event.
|
|
1069
|
+
evidence: baselinePath,
|
|
888
1070
|
recordedAt: recordOrigin({
|
|
889
1071
|
by: "cli",
|
|
890
1072
|
tool: `archkeep:v${TOOL_VERSION}`,
|
|
@@ -928,6 +1110,7 @@ export async function deltaCommand(
|
|
|
928
1110
|
|
|
929
1111
|
return {
|
|
930
1112
|
status,
|
|
1113
|
+
exitCode,
|
|
931
1114
|
eventWrite,
|
|
932
1115
|
delta: result,
|
|
933
1116
|
coverage,
|
|
@@ -941,3 +1124,54 @@ export async function deltaCommand(
|
|
|
941
1124
|
},
|
|
942
1125
|
};
|
|
943
1126
|
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* `delta --capture` as the CLI drives it: the shared preamble — command
|
|
1130
|
+
* context, then the boundary law — resolved here, so `../../cli.mjs`'s
|
|
1131
|
+
* driver only wires where the evidence text lands. The engine this returns
|
|
1132
|
+
* from is `captureDelta` above, unchanged; the driver owns the write door.
|
|
1133
|
+
*
|
|
1134
|
+
* @param {{config: string|null}} options This run's parsed flags.
|
|
1135
|
+
* @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
|
|
1136
|
+
* seams a test injects, the same ones `check` takes.
|
|
1137
|
+
* @returns {Promise<object>} `captureDelta`'s result (`snapshot`, `text`),
|
|
1138
|
+
* unmodified.
|
|
1139
|
+
*/
|
|
1140
|
+
export async function captureBaseline(options, { cwd, readGraph, listFiles }) {
|
|
1141
|
+
const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
|
|
1142
|
+
// The snapshot's policy fingerprint needs the boundary config — the
|
|
1143
|
+
// workspace's own `boundaryConfig` (profile-aware the same way `check` is,
|
|
1144
|
+
// `resolvePolicy`), so the stored fingerprint agrees with a standalone
|
|
1145
|
+
// `graph` fingerprint over the same workspace, kept in one place so a
|
|
1146
|
+
// capture and a `graph` never disagree about the current policy.
|
|
1147
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1148
|
+
return captureDelta(commandContext, { config });
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* `delta` (compare) as the CLI drives it: the baseline path resolved from
|
|
1153
|
+
* the single positional argument, then the shared preamble — command
|
|
1154
|
+
* context, boundary law — so `../../cli.mjs`'s driver only wires options,
|
|
1155
|
+
* IO seams, and where output lands. The engine this returns from is
|
|
1156
|
+
* `deltaCommand` above, unchanged.
|
|
1157
|
+
*
|
|
1158
|
+
* @param {{config: string|null, eventOut: string|null, paths: string[]}} options
|
|
1159
|
+
* This run's parsed flags; `paths[0]` is the baseline file.
|
|
1160
|
+
* @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
|
|
1161
|
+
* seams a test injects, the same ones `check` takes.
|
|
1162
|
+
* @returns {Promise<object>} `deltaCommand`'s result, unmodified.
|
|
1163
|
+
*/
|
|
1164
|
+
export async function delta(options, { cwd, readGraph, listFiles }) {
|
|
1165
|
+
const baselinePath = isAbsolute(options.paths[0])
|
|
1166
|
+
? resolve(options.paths[0])
|
|
1167
|
+
: resolve(cwd, options.paths[0]);
|
|
1168
|
+
const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
|
|
1169
|
+
// Same loading logic as `check` (`resolvePolicy`) — a `--config`
|
|
1170
|
+
// overrides the workspace's own `boundaryConfig`, profile-aware the same
|
|
1171
|
+
// way `check` is.
|
|
1172
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1173
|
+
return deltaCommand(baselinePath, commandContext, {
|
|
1174
|
+
config,
|
|
1175
|
+
eventOut: options.eventOut,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
package/src/commands/diff.mjs
CHANGED
|
@@ -42,12 +42,15 @@ import { readFileSync } from "node:fs";
|
|
|
42
42
|
|
|
43
43
|
import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
|
|
44
44
|
import { computeRuleImpact } from "../rules/edge-constraints.mjs";
|
|
45
|
+
import { isAbsolute, resolve } from "node:path";
|
|
45
46
|
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
46
47
|
import { SCHEMA_VERSION } from "../report/json.mjs";
|
|
47
48
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
48
49
|
import { formatDiffReport } from "../report/diff-text.mjs";
|
|
49
|
-
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
50
|
+
import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
|
|
50
51
|
import { resolveProvenance } from "./provenance.mjs";
|
|
52
|
+
import { resolveCommandContext } from "./context.mjs";
|
|
53
|
+
import { resolvePolicy } from "./policy.mjs";
|
|
51
54
|
|
|
52
55
|
/**
|
|
53
56
|
* Reads and validates a baseline snapshot from `path`.
|
|
@@ -236,6 +239,20 @@ function buildHeadSnapshot(commandContext) {
|
|
|
236
239
|
* consumer wants to see: it is a real architectural event, not an
|
|
237
240
|
* implementation detail.
|
|
238
241
|
*
|
|
242
|
+
* This key is in-memory arithmetic and nothing else: `Map`/`Set` keys inside
|
|
243
|
+
* one run, never persisted and never emitted — only counts derived from
|
|
244
|
+
* those sets reach an envelope. The STORED spelling of edge identity is
|
|
245
|
+
* `edgeEvolutionIdentity` (`../governance/evolution-event.mjs`), the escaped
|
|
246
|
+
* `source>target:type` string evolution events carry, and the two spellings
|
|
247
|
+
* are deliberately not unified — each medium keeps exactly one identity
|
|
248
|
+
* constructor (`../../../../docs/adr/0008-snapshot-identity-per-family.md`;
|
|
249
|
+
* INV-6 in `../../../../docs/architecture/refactor/INVARIANTS.md`). An edge
|
|
250
|
+
* crosses from a structural diff into an event as its raw
|
|
251
|
+
* `{source, target, type}` triple, mapped through that function at the
|
|
252
|
+
* command boundary; a `\0`-joined key written into an event record would
|
|
253
|
+
* give one edge two spellings inside the store, and anything comparing
|
|
254
|
+
* identity strings across events would read them as different boundaries.
|
|
255
|
+
*
|
|
239
256
|
* @param {{source: string, target: string, type: string}} edge
|
|
240
257
|
* @returns {string}
|
|
241
258
|
*/
|
|
@@ -475,16 +492,10 @@ export function diffCommand(
|
|
|
475
492
|
}
|
|
476
493
|
|
|
477
494
|
if (meta.dirtyBaseline) {
|
|
478
|
-
coverage.notes.push(
|
|
479
|
-
"the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
|
|
480
|
-
"claim about the commit it names",
|
|
481
|
-
);
|
|
495
|
+
coverage.notes.push(dirtyBaselineNote(false));
|
|
482
496
|
}
|
|
483
497
|
if (meta.dirtyHead) {
|
|
484
|
-
coverage.notes.push(
|
|
485
|
-
"this run's working tree is dirty — the head side describes uncommitted state, not the " +
|
|
486
|
-
"commit HEAD names",
|
|
487
|
-
);
|
|
498
|
+
coverage.notes.push(dirtyHeadNote());
|
|
488
499
|
}
|
|
489
500
|
|
|
490
501
|
if (meta.policyChanged === true) {
|
|
@@ -564,3 +575,31 @@ export function diffCommand(
|
|
|
564
575
|
},
|
|
565
576
|
};
|
|
566
577
|
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* `diff` as the CLI drives it: the baseline path resolved from the single
|
|
581
|
+
* positional argument, then the shared preamble — command context, boundary
|
|
582
|
+
* law — so `../../cli.mjs`'s driver only wires options, IO seams, and where
|
|
583
|
+
* output lands (`./README.md`). The engine this returns from is
|
|
584
|
+
* `diffCommand` above, unchanged.
|
|
585
|
+
*
|
|
586
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
587
|
+
* This run's parsed flags; `paths[0]` is the baseline file.
|
|
588
|
+
* @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
|
|
589
|
+
* seams a test injects, the same ones `check` takes.
|
|
590
|
+
* @returns {Promise<object>} `diffCommand`'s result, unmodified.
|
|
591
|
+
*/
|
|
592
|
+
export async function diff(options, { cwd, readGraph, listFiles }) {
|
|
593
|
+
const baselinePath = isAbsolute(options.paths[0])
|
|
594
|
+
? options.paths[0]
|
|
595
|
+
: resolve(cwd, options.paths[0]);
|
|
596
|
+
const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
|
|
597
|
+
// Load the boundary config when --config is given or when the workspace
|
|
598
|
+
// declares one, so rule-impact analysis is computed. Without a config,
|
|
599
|
+
// the diff reports only structural changes — same as before. A
|
|
600
|
+
// profile-selected workspace resolves the same way `check` does
|
|
601
|
+
// (`resolvePolicy`), so a policy edit under an unchanged profile NAME is
|
|
602
|
+
// still visible as a fingerprint change here.
|
|
603
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
604
|
+
return diffCommand(baselinePath, commandContext, { config });
|
|
605
|
+
}
|
|
@@ -122,6 +122,43 @@ export function proposalToIntent(proposal) {
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Serialize a proposal into the exact bytes `--write-intent` plants at the
|
|
127
|
+
* target path — `proposalToIntent` rendered as stable, two-space JSON with a
|
|
128
|
+
* trailing newline, so the file a human reviews as a diff is the file
|
|
129
|
+
* `check` would read. Owned here, beside the conversion it renders, so the
|
|
130
|
+
* CLI's write door can never re-derive a second spelling of it.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} proposal The proposal from `discoverCommand`'s result.
|
|
133
|
+
* @returns {string} The serialized intent document.
|
|
134
|
+
*/
|
|
135
|
+
export function intentJsonFromProposal(proposal) {
|
|
136
|
+
return JSON.stringify(proposalToIntent(proposal), null, 2) + "\n";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The `--write-intent` self-footgun refusal, decided beside the proposal it
|
|
141
|
+
* protects. A proposal is a suggestion; a file already at the target is a law
|
|
142
|
+
* (or a candidate someone holds), and silently overwriting it with a proposal
|
|
143
|
+
* is the adoption this command must never perform by itself — the same
|
|
144
|
+
* posture `--output`'s fixed-name table holds at the write door, specialized
|
|
145
|
+
* to the one target `discover` itself names. The CLI keeps the mechanics
|
|
146
|
+
* (the `wx` write, the stderr wording around it); the DECISION to refuse
|
|
147
|
+
* travels with the verb, the way `historyOutputRefusal` does for history.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} target The `--write-intent` path as the user wrote it.
|
|
150
|
+
* @param {{exists: (path: string) => boolean}} io Injectable existence read —
|
|
151
|
+
* the same seam style `loadNativeModel`'s `{readFile}` keeps.
|
|
152
|
+
* @returns {string|null} The refusal message, or `null` when the write may
|
|
153
|
+
* proceed.
|
|
154
|
+
*/
|
|
155
|
+
export function intentWriteRefusal(target, { exists }) {
|
|
156
|
+
return exists(target)
|
|
157
|
+
? `archkeep: ${target} already exists, and a proposal must never ` +
|
|
158
|
+
`silently replace what is there. Move or delete the file first, then run this again.`
|
|
159
|
+
: null;
|
|
160
|
+
}
|
|
161
|
+
|
|
125
162
|
/**
|
|
126
163
|
* Runs the `discover` command: observes the workspace, optionally proposes the
|
|
127
164
|
* candidate architecture over it, and returns the report.
|
package/src/commands/drift.mjs
CHANGED
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
* intent produce byte-identical text and JSON.
|
|
78
78
|
*/
|
|
79
79
|
import { blindSpotRows } from "../analysis/source-util.mjs";
|
|
80
|
+
import { resolveCommandContext } from "./context.mjs";
|
|
81
|
+
import { resolvePolicy } from "./policy.mjs";
|
|
80
82
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
81
83
|
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
82
84
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
@@ -471,3 +473,46 @@ export async function driftCommand(commandContext, io = {}) {
|
|
|
471
473
|
},
|
|
472
474
|
};
|
|
473
475
|
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* `drift` as the CLI drives it: the shared preamble — command context, then
|
|
479
|
+
* the boundary law — resolved here so `../../cli.mjs`'s driver only wires
|
|
480
|
+
* options, IO seams, and where output lands (`./README.md`). The engine this
|
|
481
|
+
* returns from is `driftCommand` above, unchanged.
|
|
482
|
+
*
|
|
483
|
+
* The loaded policy — profile-aware the same way `check` is
|
|
484
|
+
* (`resolvePolicy`), `null` when the workspace declares none. Drift reads
|
|
485
|
+
* the intent's rows, and the fitness half of a row's `decisionRef` resolves
|
|
486
|
+
* against the ids THIS policy declares (F04), so the same policy that made
|
|
487
|
+
* the boundary law answerable to the model must answer here. `drift` has no
|
|
488
|
+
* `--config` (`DRIFT_FLAG_HELP`), so `config` is always the workspace's own
|
|
489
|
+
* default — resolvePolicy reads `options.config` as the override, hence
|
|
490
|
+
* `null` here, which selects the workspace's configured boundary law (or a
|
|
491
|
+
* profile, when one is registered).
|
|
492
|
+
*
|
|
493
|
+
* The failure is DEFERRED rather than thrown here. `drift`'s only reader of
|
|
494
|
+
* this policy is the non-verdict decisionRef axis, and only for rows that
|
|
495
|
+
* carry one, so a workspace with an intent and no boundary config was
|
|
496
|
+
* exiting 3 over a law drift would never have opened — a fifth refusal
|
|
497
|
+
* neither `docs/usage/drift.md` nor `reconcile`, which makes the same four,
|
|
498
|
+
* ever had. `driftCommand` rethrows it, unchanged, at the one site that
|
|
499
|
+
* reads the policy, so every workspace whose intent cites anything keeps the
|
|
500
|
+
* exact exit-3 it had.
|
|
501
|
+
*
|
|
502
|
+
* @param {{format: string, output: string|null, paths: string[]}} options
|
|
503
|
+
* This run's parsed flags.
|
|
504
|
+
* @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
|
|
505
|
+
* seams a test injects, the same ones `check` takes.
|
|
506
|
+
* @returns {Promise<object>} `driftCommand`'s result, unmodified.
|
|
507
|
+
*/
|
|
508
|
+
export async function drift(options, { cwd, readGraph, listFiles }) {
|
|
509
|
+
const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
|
|
510
|
+
let config = null;
|
|
511
|
+
let configError = null;
|
|
512
|
+
try {
|
|
513
|
+
({ config } = await resolvePolicy({ ...options, config: null }, commandContext, cwd));
|
|
514
|
+
} catch (error) {
|
|
515
|
+
configError = /** @type {Error} */ (error);
|
|
516
|
+
}
|
|
517
|
+
return driftCommand(commandContext, { config, configError });
|
|
518
|
+
}
|