@ecoma-io/archkeep 0.13.0 → 0.15.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 +9 -3
- package/cli.mjs +599 -55
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +70 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +84 -17
- package/src/commands/context.mjs +92 -16
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +664 -0
- package/src/commands/delta-snapshot.mjs +672 -0
- package/src/commands/delta.mjs +606 -0
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +93 -1
- package/src/commands/trajectory.mjs +437 -0
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/path-util.mjs +40 -0
- package/src/providers/native/model.mjs +17 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +264 -0
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +280 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/text.mjs +36 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/report/waivers-text.mjs +35 -2
- package/src/tsconfig-paths.mjs +3 -2
package/src/config.mjs
CHANGED
|
@@ -96,7 +96,8 @@
|
|
|
96
96
|
*
|
|
97
97
|
* The `.mjs`/`.js` and `.json` dialects share the same top-level key law: a
|
|
98
98
|
* top-level export (or key) beyond `depConstraints`, `moduleBoundaryOptions`,
|
|
99
|
-
* `boundarySuppressions`, `fitness` and `
|
|
99
|
+
* `boundarySuppressions`, `fitness`, `customRules` and `coverage` is rejected
|
|
100
|
+
* by name. The
|
|
100
101
|
* `.mjs` dialect's tolerance for a helper export used to let a misspelled key
|
|
101
102
|
* (`moduleBoundaryOptions` → `moduleBoundaryOption`) disappear into silence —
|
|
102
103
|
* a typo'd law is a law that is not enforced, the exact silent direction this
|
|
@@ -557,6 +558,94 @@ function suppressionRowViolations(row, index) {
|
|
|
557
558
|
return violations;
|
|
558
559
|
}
|
|
559
560
|
|
|
561
|
+
/**
|
|
562
|
+
* The keys a `coverage.unowned` row may carry. `reason` is not optional, for
|
|
563
|
+
* the same reason it is not optional on a suppression row above and on a
|
|
564
|
+
* native `coverage.exempt` row (`./providers/native/model.mjs`'s
|
|
565
|
+
* `exemptRowViolations`, whose semantics this row copies exactly): an
|
|
566
|
+
* accepted coverage hole with no reason written down is indistinguishable
|
|
567
|
+
* from coverage that quietly stopped being enforced.
|
|
568
|
+
*/
|
|
569
|
+
const COVERAGE_UNOWNED_KEYS = ["path", "reason"];
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* One `coverage.unowned` row's problems, prefixed with its index.
|
|
573
|
+
*
|
|
574
|
+
* @param {unknown} row
|
|
575
|
+
* @param {number} index
|
|
576
|
+
* @returns {string[]}
|
|
577
|
+
*/
|
|
578
|
+
function coverageUnownedRowViolations(row, index) {
|
|
579
|
+
const at = `coverage.unowned[${index}]`;
|
|
580
|
+
if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
|
|
581
|
+
|
|
582
|
+
const violations = [];
|
|
583
|
+
if (typeof row.path !== "string" || row.path === "") {
|
|
584
|
+
violations.push(
|
|
585
|
+
`${at}.path: must be a non-empty glob over a workspace-relative path, matched with ` +
|
|
586
|
+
`\`path.posix.matchesGlob\`, got ${describe(row.path)}`,
|
|
587
|
+
);
|
|
588
|
+
} else {
|
|
589
|
+
const problem = globComplexityError(row.path);
|
|
590
|
+
if (problem) violations.push(`${at}.path: '${row.path}' ${problem}`);
|
|
591
|
+
}
|
|
592
|
+
if (typeof row.reason !== "string" || row.reason.trim() === "") {
|
|
593
|
+
violations.push(
|
|
594
|
+
`${at}.reason: must be a non-empty string — an accepted coverage hole with no reason ` +
|
|
595
|
+
`written down reads as coverage that is still enforced`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
for (const key of Object.keys(row)) {
|
|
599
|
+
if (!COVERAGE_UNOWNED_KEYS.includes(key)) {
|
|
600
|
+
violations.push(
|
|
601
|
+
`${at}.${key}: not a coverage.unowned field — expected one of ` +
|
|
602
|
+
COVERAGE_UNOWNED_KEYS.join(", "),
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return violations;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Everything wrong with a policy's `coverage` key, as messages; empty when it
|
|
611
|
+
* is well-formed.
|
|
612
|
+
*
|
|
613
|
+
* The key holds exactly one field: `unowned`, an array of `{path, reason}`
|
|
614
|
+
* rows — a recorded acceptance of tracked files no project owns, for the Nx
|
|
615
|
+
* and Moon providers whose project model has no channel of its own for that
|
|
616
|
+
* decision (`../../../docs/reference/policy-schema.md`, "`coverage`"). A row
|
|
617
|
+
* is matched ONLY against files already decided to be unowned, so even a `**`
|
|
618
|
+
* row can never silence a verdict about an owned file — the same guarantee
|
|
619
|
+
* the native provider's `coverage.exempt` states
|
|
620
|
+
* (`./providers/native/coverage.mjs`). An empty `unowned` list is accepted
|
|
621
|
+
* for the reason an empty suppression list is: it accepts nothing, which is
|
|
622
|
+
* the direction that cannot hide anything.
|
|
623
|
+
*
|
|
624
|
+
* @param {unknown} value The parsed `coverage` value.
|
|
625
|
+
* @returns {string[]}
|
|
626
|
+
*/
|
|
627
|
+
function findCoverageViolations(value) {
|
|
628
|
+
if (!isPlainObject(value)) {
|
|
629
|
+
return [`coverage: must be an object carrying an 'unowned' array, got ${describe(value)}`];
|
|
630
|
+
}
|
|
631
|
+
const violations = [];
|
|
632
|
+
if (!Array.isArray(value.unowned)) {
|
|
633
|
+
violations.push(
|
|
634
|
+
`coverage.unowned: must be an array of {path, reason} rows, got ${describe(value.unowned)}`,
|
|
635
|
+
);
|
|
636
|
+
} else {
|
|
637
|
+
value.unowned.forEach((row, index) =>
|
|
638
|
+
violations.push(...coverageUnownedRowViolations(row, index)),
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
for (const key of Object.keys(value)) {
|
|
642
|
+
if (key !== "unowned") {
|
|
643
|
+
violations.push(`coverage.${key}: not a coverage field — expected 'unowned'`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return violations;
|
|
647
|
+
}
|
|
648
|
+
|
|
560
649
|
/**
|
|
561
650
|
* The grammar a custom rule's `name` is written in: lowercase letters and
|
|
562
651
|
* digits, single `-` separators, nothing else.
|
|
@@ -863,8 +952,14 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
863
952
|
if (!isPlainObject(module)) return [`config: expected a module object, got ${describe(module)}`];
|
|
864
953
|
|
|
865
954
|
const violations = [];
|
|
866
|
-
const {
|
|
867
|
-
|
|
955
|
+
const {
|
|
956
|
+
depConstraints,
|
|
957
|
+
moduleBoundaryOptions,
|
|
958
|
+
boundarySuppressions,
|
|
959
|
+
fitness,
|
|
960
|
+
customRules,
|
|
961
|
+
coverage,
|
|
962
|
+
} = module;
|
|
868
963
|
// F05: the resolution half of the governance block (`row-schema.mjs`'s
|
|
869
964
|
// `io.resolve`) was validator-only until now — no production caller passed
|
|
870
965
|
// one, so a row bound to a fitness rule that does not exist loaded and ran
|
|
@@ -909,6 +1004,16 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
909
1004
|
violations.push(...findCustomRuleViolations(customRules, io));
|
|
910
1005
|
}
|
|
911
1006
|
|
|
1007
|
+
// The unowned-file acceptances — the sixth top-level law, shaped here and
|
|
1008
|
+
// matched by `./commands/coverage-acceptance.mjs` against files already
|
|
1009
|
+
// decided to be unowned, never against owned ones. Absent means "no
|
|
1010
|
+
// acceptance recorded"; present and malformed is refused loudly, because a
|
|
1011
|
+
// row this reader could not understand is an acceptance that would not
|
|
1012
|
+
// apply while the policy still says it does.
|
|
1013
|
+
if (coverage !== undefined) {
|
|
1014
|
+
violations.push(...findCoverageViolations(coverage));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
912
1017
|
// Absent means "nothing is suppressed", which is the only default that fails
|
|
913
1018
|
// toward reporting — unlike the eight options above, where a missing value
|
|
914
1019
|
// would be a second copy of something ESLint also reads and this module has
|
|
@@ -996,8 +1101,15 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
996
1101
|
* general "ignore unknown" rule, which is exactly the leniency this file's
|
|
997
1102
|
* header argues a JSON object must not get). `fitness` is the fourth: the
|
|
998
1103
|
* boundary dialect's key for the fitness-functions list, validated as an array
|
|
999
|
-
* of fitness rows. `customRules` is the fifth
|
|
1104
|
+
* of fitness rows. `customRules` is the fifth — the declared rules
|
|
1000
1105
|
* this engine did not write, validated as an array of custom-rule rows.
|
|
1106
|
+
* `coverage` is the sixth and newest: the recorded acceptances of unowned
|
|
1107
|
+
* files on an Nx or Moon workspace (`findCoverageViolations` above owns the
|
|
1108
|
+
* shape). On a native tree the key is refused — `archkeep.json`'s own
|
|
1109
|
+
* `coverage.exempt` is that provider's one channel for the same decision —
|
|
1110
|
+
* and the refusal lives in `./commands/policy.mjs`'s `resolvePolicy` and
|
|
1111
|
+
* `./providers/native/model.mjs`'s inline-policy check, because only they
|
|
1112
|
+
* know which provider is reading.
|
|
1001
1113
|
*
|
|
1002
1114
|
* The name says `.json` and the list binds both file dialects: `loadModulePolicy`
|
|
1003
1115
|
* runs the same check over an ES module's exports, which is what makes a
|
|
@@ -1010,6 +1122,7 @@ const JSON_POLICY_KEYS = [
|
|
|
1010
1122
|
"boundarySuppressions",
|
|
1011
1123
|
"fitness",
|
|
1012
1124
|
"customRules",
|
|
1125
|
+
"coverage",
|
|
1013
1126
|
];
|
|
1014
1127
|
|
|
1015
1128
|
/**
|
|
@@ -1086,7 +1199,7 @@ export function policyKeyViolations(parsed, { allowSchema }) {
|
|
|
1086
1199
|
* inline one.
|
|
1087
1200
|
* @param {string[]} [extraViolations] Violations the caller already found that
|
|
1088
1201
|
* `findBoundaryConfigViolations` does not check on its own.
|
|
1089
|
-
* @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }}
|
|
1202
|
+
* @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }}
|
|
1090
1203
|
* `fitness` and `customRules` are present only when the config declares
|
|
1091
1204
|
* them — a workspace without one carries no key, the same "absent is a
|
|
1092
1205
|
* decision" posture `cli.mjs`'s `check` uses for a missing
|
|
@@ -1109,6 +1222,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
|
|
|
1109
1222
|
suppressions: parsed.boundarySuppressions ?? [],
|
|
1110
1223
|
...(parsed.fitness === undefined ? {} : { fitness: parsed.fitness }),
|
|
1111
1224
|
...(parsed.customRules === undefined ? {} : { customRules: parsed.customRules }),
|
|
1225
|
+
...(parsed.coverage === undefined ? {} : { coverage: parsed.coverage }),
|
|
1112
1226
|
};
|
|
1113
1227
|
}
|
|
1114
1228
|
|
|
@@ -1125,7 +1239,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
|
|
|
1125
1239
|
* legitimate namespace to share a helper in gave a typo the same silence.
|
|
1126
1240
|
*
|
|
1127
1241
|
* @param {string} path Absolute path of the config file.
|
|
1128
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
1242
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
|
|
1129
1243
|
* @throws {Error} when the file is missing, unloadable, or malformed.
|
|
1130
1244
|
*/
|
|
1131
1245
|
async function loadModulePolicy(path) {
|
|
@@ -1151,7 +1265,7 @@ async function loadModulePolicy(path) {
|
|
|
1151
1265
|
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
1152
1266
|
* Injectable read, defaulting to `node:fs/promises`' `readFile` — the only
|
|
1153
1267
|
* code in this function that reaches outside the process.
|
|
1154
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
1268
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
|
|
1155
1269
|
* @throws {Error} when the file is missing, unreadable, not valid JSON, or
|
|
1156
1270
|
* malformed — either by `findBoundaryConfigViolations`' rules or by carrying
|
|
1157
1271
|
* a top-level key none of those rules knows about.
|
|
@@ -1198,7 +1312,7 @@ async function loadJsonPolicy(path, { readFile = readFileFromDisk } = {}) {
|
|
|
1198
1312
|
* @param {string} path Absolute path of the config file.
|
|
1199
1313
|
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
1200
1314
|
* Injectable read, used only by the `.json` dialect — see `loadJsonPolicy`.
|
|
1201
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[] }>}
|
|
1315
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, notes?: string[] }>}
|
|
1202
1316
|
* `suppressions` is `[]` when the config declares none. `notes` is present
|
|
1203
1317
|
* only under the ESLint dialect, and only when `./eslint-config.mjs` has
|
|
1204
1318
|
* something worth telling a reader about which entry it bound — see
|
|
@@ -1250,8 +1364,12 @@ export async function loadBoundaryConfigFile(path, io = {}) {
|
|
|
1250
1364
|
// from (see this module's header). Never populated under this dialect —
|
|
1251
1365
|
// `policyFrom` already resolves that to `[]` since the object above
|
|
1252
1366
|
// states no `boundarySuppressions` key, and it leaves `customRules`
|
|
1253
|
-
// absent for the same reason, the header's own
|
|
1254
|
-
// absent law and an empty one
|
|
1367
|
+
// and `coverage` absent for the same reason, the header's own
|
|
1368
|
+
// distinction between an absent law and an empty one: a flat config's
|
|
1369
|
+
// one rule entry is a constraint table and its options, with nowhere to
|
|
1370
|
+
// name a rule artifact or an unowned-file acceptance
|
|
1371
|
+
// (`../../../docs/reference/policy-schema.md`'s dialect table names both
|
|
1372
|
+
// gaps on the consumer-facing side).
|
|
1255
1373
|
...(note !== undefined ? { notes: [note] } : {}),
|
|
1256
1374
|
};
|
|
1257
1375
|
}
|
|
@@ -1279,7 +1397,7 @@ export async function loadBoundaryConfigFile(path, io = {}) {
|
|
|
1279
1397
|
* misconfigured tool.
|
|
1280
1398
|
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
1281
1399
|
* Forwarded to `loadBoundaryConfigFile` — see there.
|
|
1282
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[] }>}
|
|
1400
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, notes?: string[] }>}
|
|
1283
1401
|
* @throws {Error} as `loadBoundaryConfigFile`.
|
|
1284
1402
|
*/
|
|
1285
1403
|
export async function loadBoundaryConfig(workspaceRoot, boundaryConfig, io = {}) {
|
|
@@ -62,7 +62,7 @@ import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
|
|
|
62
62
|
*
|
|
63
63
|
* @param {string} path Absolute path of the config file.
|
|
64
64
|
* @param {string|number} revision Busts the ESM module cache across edits.
|
|
65
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
65
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
|
|
66
66
|
* @throws {Error} when the file is missing, unloadable, or malformed.
|
|
67
67
|
*/
|
|
68
68
|
async function readModulePolicy(path, revision) {
|
|
@@ -86,7 +86,7 @@ async function readModulePolicy(path, revision) {
|
|
|
86
86
|
* @param {(path: string, encoding: "utf8") => Promise<string>} readFile
|
|
87
87
|
* Injected so a test can drive this without a real file — see
|
|
88
88
|
* `readBoundaryConfig` below.
|
|
89
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
89
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
|
|
90
90
|
* @throws {Error} when the file is missing, unreadable, not valid JSON, or
|
|
91
91
|
* malformed — either by `../config.mjs`'s `findBoundaryConfigViolations` or
|
|
92
92
|
* by carrying a top-level key none of those rules knows about.
|
|
@@ -144,14 +144,19 @@ async function readJsonPolicy(path, readFile) {
|
|
|
144
144
|
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
145
145
|
* Injectable read, used only by the `.json` dialect, defaulting to
|
|
146
146
|
* `node:fs/promises`'s `readFile`.
|
|
147
|
-
* @returns {Promise<{depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[]}>}
|
|
147
|
+
* @returns {Promise<{depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object}>}
|
|
148
148
|
* `suppressions` is `[]` when the config declares none; `fitness` and
|
|
149
149
|
* `customRules` are present only when the policy declares them, the same
|
|
150
150
|
* absent-is-a-decision shape `../config.mjs`'s `policyFrom` returns to
|
|
151
151
|
* every other face. This server READS both and evaluates neither — a
|
|
152
152
|
* fitness function and a custom rule are per-run workspace judgments, not
|
|
153
153
|
* per-file diagnostics — so what it owes them is to load them and to fail
|
|
154
|
-
* loudly on a row it cannot read.
|
|
154
|
+
* loudly on a row it cannot read. `coverage` rides the same bargain one
|
|
155
|
+
* key further: unowned-file acceptance is a whole-run coverage decision
|
|
156
|
+
* (`../commands/coverage-acceptance.mjs`), never a per-file diagnostic, so
|
|
157
|
+
* the server's whole duty to the key is to ACCEPT a config carrying it —
|
|
158
|
+
* the same `policyKeyViolations` the CLI runs — rather than reject in the
|
|
159
|
+
* editor a law `check` enforces.
|
|
155
160
|
* @throws {Error} when `boundaryConfig` names the ESLint flat-config dialect
|
|
156
161
|
* or a legacy `.eslintrc*` file (this reader does not read either yet — see
|
|
157
162
|
* above), or — for a dialect it does read — when the file is missing,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path utilities — operations that would otherwise be scattered as inline
|
|
3
|
+
* expressions, each a copy that could drift.
|
|
4
|
+
*
|
|
5
|
+
* Every function here is pure and typed, so tests need no filesystem.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strip trailing slashes from a path — the O(n) alternative to
|
|
10
|
+
* `.replace(/\/+$/u, "")`.
|
|
11
|
+
*
|
|
12
|
+
* The regex form is **vulnerable to polynomial ReDoS** on a run of slashes
|
|
13
|
+
* that does not reach the string end: `/" + "/×n + "a"` forces the engine to
|
|
14
|
+
* scan from each position within the run, turning linear work into O(n²).
|
|
15
|
+
* Measured on V8:
|
|
16
|
+
*
|
|
17
|
+
* | n (slash count) | regex | linear scan |
|
|
18
|
+
* |-----------------|--------------|-------------|
|
|
19
|
+
* | 20,000 | 0.24 s | <0.001 s |
|
|
20
|
+
* | 40,000 | 0.86 s | <0.001 s |
|
|
21
|
+
* | 80,000 | 3.4 s | 0.004 s |
|
|
22
|
+
* | 160,000 | 13.6 s | 0.14 ms |
|
|
23
|
+
*
|
|
24
|
+
* Workspace roots are normally bounded by PATH_MAX (~4096), so the practical
|
|
25
|
+
* impact is low; but this function is shipped in a published package and
|
|
26
|
+
* CodeQL correctly flags the `workspaceRoot` argument as library input — the
|
|
27
|
+
* fix removes the alert class rather than arguing severity. Five call sites
|
|
28
|
+
* in the codebase used this pattern; all now route through this one helper.
|
|
29
|
+
*
|
|
30
|
+
* Semantics are identical to the regex on all edge cases (empty string,
|
|
31
|
+
* single slash, multiple slashes, no trailing slash, mixed content).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} path The path to strip.
|
|
34
|
+
* @returns {string} `path` with trailing `/` characters removed.
|
|
35
|
+
*/
|
|
36
|
+
export function stripTrailingSlashes(path) {
|
|
37
|
+
let end = path.length;
|
|
38
|
+
while (end > 0 && path[end - 1] === "/") end -= 1;
|
|
39
|
+
return path.slice(0, end);
|
|
40
|
+
}
|
|
@@ -520,6 +520,23 @@ export function findNativeModelViolations(raw) {
|
|
|
520
520
|
(message) => `boundaryConfig.${message}`,
|
|
521
521
|
),
|
|
522
522
|
);
|
|
523
|
+
// The policy's `coverage` key is an Nx/Moon channel, refused here BY
|
|
524
|
+
// NAME on the one provider that already has its own: this very file's
|
|
525
|
+
// `coverage.exempt` is where a native tree records an unowned-file
|
|
526
|
+
// acceptance, and two channels for one decision on one tree is how
|
|
527
|
+
// copies drift (the same posture as the `.moon`-beside-`archkeep.json`
|
|
528
|
+
// refusal in `../../commands/context.mjs`'s `requireSingleProjectModel`).
|
|
529
|
+
// The file-dialect spelling of the same mistake — a native tree whose
|
|
530
|
+
// boundaryConfig FILE declares `coverage` — is refused by
|
|
531
|
+
// `../../commands/policy.mjs`'s `resolvePolicy`, which is the first
|
|
532
|
+
// point that knows both the provider and the loaded policy.
|
|
533
|
+
if ("coverage" in raw.boundaryConfig) {
|
|
534
|
+
violations.push(
|
|
535
|
+
`boundaryConfig.coverage: not accepted on a native workspace — this tree records ` +
|
|
536
|
+
`unowned-file acceptances on archkeep.json's own coverage.exempt, and a second ` +
|
|
537
|
+
`channel for the same decision is a copy that will drift. Move the rows there`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
523
540
|
} else if (typeof raw.boundaryConfig !== "string") {
|
|
524
541
|
violations.push(
|
|
525
542
|
`boundaryConfig: must be a string (a filename) or an object (an inline policy), got ` +
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `change` command: a declared change-intent
|
|
3
|
+
* contract against the architectural delta actually observed
|
|
4
|
+
* (`../commands/change.mjs`).
|
|
5
|
+
*
|
|
6
|
+
* The report is a review document, so it renders every axis separately and in
|
|
7
|
+
* full: what was declared, what matched, what appeared without a declaration,
|
|
8
|
+
* which declared changes never happened, how each declared constraint judged,
|
|
9
|
+
* and — informational, never this command's verdict — how many live boundary
|
|
10
|
+
* violations the tree currently carries under the current law, with `check`
|
|
11
|
+
* named as the authority on that axis. A section renders only when it has
|
|
12
|
+
* content; the closing line always states what was compared and with what
|
|
13
|
+
* outcome, so an empty reconciliation is a verifiable claim rather than
|
|
14
|
+
* silence (`../../../../AGENTS.md`).
|
|
15
|
+
*
|
|
16
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
17
|
+
* wearing a formatter's name (`./README.md`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One expected-fact row as report lines. */
|
|
21
|
+
function factLines(entry) {
|
|
22
|
+
switch (entry.kind) {
|
|
23
|
+
case "project-added":
|
|
24
|
+
return [` + project ${entry.project}`];
|
|
25
|
+
case "project-removed":
|
|
26
|
+
return [` - project ${entry.project}`];
|
|
27
|
+
case "edge-added":
|
|
28
|
+
return [
|
|
29
|
+
` + edge ${entry.from} -> ${entry.to}${entry.type === undefined ? "" : ` (${entry.type})`}`,
|
|
30
|
+
];
|
|
31
|
+
case "edge-removed":
|
|
32
|
+
return [
|
|
33
|
+
` - edge ${entry.from} -> ${entry.to}${entry.type === undefined ? "" : ` (${entry.type})`}`,
|
|
34
|
+
];
|
|
35
|
+
case "project-changed": {
|
|
36
|
+
const lines = [` ! project ${entry.project} changed:`];
|
|
37
|
+
for (const change of entry.changes ?? []) {
|
|
38
|
+
lines.push(
|
|
39
|
+
` ${change.field}: ${JSON.stringify(change.baseline)} -> ${JSON.stringify(change.head)}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
default:
|
|
45
|
+
return [` ? ${entry.kind} ${entry.project ?? `${entry.from} -> ${entry.to}`}`];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One constraint verdict row as its report lines. */
|
|
50
|
+
function constraintLines(row) {
|
|
51
|
+
const glyph = row.verdict === "pass" ? "✔" : row.verdict === "fail" ? "✗" : "?";
|
|
52
|
+
return [`${glyph} ${row.name}: ${row.verdict} — ${row.message}`];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A side's identity for prose: its commit prefix, or an honest absence. */
|
|
56
|
+
function describeOrigin(provenance) {
|
|
57
|
+
if (!provenance || typeof provenance.commit !== "string") return "unverified origin";
|
|
58
|
+
const dirty = provenance.dirty ? ", dirty" : "";
|
|
59
|
+
return `${provenance.commit.slice(0, 8)}${dirty}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The whole change report.
|
|
64
|
+
*
|
|
65
|
+
* @param {{change: object, coverage: object}} input `change` is
|
|
66
|
+
* `../commands/change.mjs`'s result payload; `coverage` its coverage block.
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function formatChangeReport({ change, coverage }) {
|
|
70
|
+
const { intent, baseline, head, reconciliation, constraints, policy } = change;
|
|
71
|
+
const sections = [];
|
|
72
|
+
|
|
73
|
+
sections.push(
|
|
74
|
+
`intent ${intent.file} — base ${intent.base.commit.slice(0, 8)}` +
|
|
75
|
+
(intent.summary === undefined ? "" : `\n "${intent.summary}"`),
|
|
76
|
+
);
|
|
77
|
+
sections.push(
|
|
78
|
+
`baseline ${baseline.path} — ${describeOrigin(baseline.provenance)}, ` +
|
|
79
|
+
`${baseline.records} record${baseline.records === 1 ? "" : "s"}, ` +
|
|
80
|
+
`${baseline.projects} project${baseline.projects === 1 ? "" : "s"}`,
|
|
81
|
+
);
|
|
82
|
+
sections.push(
|
|
83
|
+
`head ${describeOrigin(head.provenance)}, ` +
|
|
84
|
+
`${head.projects} project${head.projects === 1 ? "" : "s"}`,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
for (const note of coverage.notes ?? []) sections.push(`⚠ ${note}`);
|
|
88
|
+
|
|
89
|
+
const verdictLine = {
|
|
90
|
+
matched: "✔ MATCHED — the delta is exactly the declared change",
|
|
91
|
+
undeclared: "⚠ UNDECLARED — the delta contains changes no declaration covers",
|
|
92
|
+
unfulfilled: "✗ UNFULFILLED — nothing undeclared, but declared changes never happened",
|
|
93
|
+
unproven: "? UNPROVEN — the base identity could not be established",
|
|
94
|
+
}[reconciliation.verdict];
|
|
95
|
+
sections.push(`reconciliation ${verdictLine}`);
|
|
96
|
+
for (const reason of reconciliation.reasons) sections.push(` because: ${reason}`);
|
|
97
|
+
|
|
98
|
+
if (reconciliation.matched.length > 0) {
|
|
99
|
+
sections.push(
|
|
100
|
+
`✔ ${reconciliation.matched.length} declared change${reconciliation.matched.length === 1 ? "" : "s"} observed`,
|
|
101
|
+
...reconciliation.matched.flatMap(factLines),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (reconciliation.unexpected.length > 0) {
|
|
105
|
+
sections.push(
|
|
106
|
+
`! ${reconciliation.unexpected.length} undeclared material change${reconciliation.unexpected.length === 1 ? "" : "s"} — a review signal, not a law verdict`,
|
|
107
|
+
...reconciliation.unexpected.flatMap(factLines),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (reconciliation.missingExpected.length > 0) {
|
|
111
|
+
sections.push(
|
|
112
|
+
`? ${reconciliation.missingExpected.length} declared change${reconciliation.missingExpected.length === 1 ? "" : "s"} never observed`,
|
|
113
|
+
...reconciliation.missingExpected.flatMap(factLines),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (constraints.length > 0) {
|
|
118
|
+
sections.push("declared constraints");
|
|
119
|
+
sections.push(...constraints.flatMap(constraintLines));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Informational on purpose: this number says what `check` would count right
|
|
123
|
+
// now. It gates nothing here — collapsing it into the intent verdict would
|
|
124
|
+
// hide one signal behind the other.
|
|
125
|
+
sections.push(
|
|
126
|
+
policy.liveViolations === null
|
|
127
|
+
? `workspace law not evaluated — the run could not prove the base identity (archkeep check remains the authority)`
|
|
128
|
+
: `workspace law ${policy.liveViolations} live violation${policy.liveViolations === 1 ? "" : "s"} under the current law${policy.changedSinceBase ? ", which changed since capture" : ""} — informational; archkeep check remains the authoritative verdict`,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// The closing claim always states what was compared, so every outcome —
|
|
132
|
+
// including a full match over an unchanged tree — is a verifiable statement
|
|
133
|
+
// rather than silence.
|
|
134
|
+
const declaredCount =
|
|
135
|
+
intent.declared.projectsAdd +
|
|
136
|
+
intent.declared.projectsRemove +
|
|
137
|
+
intent.declared.edgesAdd +
|
|
138
|
+
intent.declared.edgesRemove;
|
|
139
|
+
sections.push(
|
|
140
|
+
`reconciled ${declaredCount} declared change${declaredCount === 1 ? "" : "s"} and ` +
|
|
141
|
+
`${constraints.length} declared constraint${constraints.length === 1 ? "" : "s"} — ` +
|
|
142
|
+
`base ${describeOrigin(baseline.provenance)} (${baseline.projects} projects, ` +
|
|
143
|
+
`${baseline.records} records) against head ${describeOrigin(head.provenance)} ` +
|
|
144
|
+
`(${head.projects} projects)`,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return sections.join("\n");
|
|
148
|
+
}
|