@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/report/sarif.mjs
CHANGED
|
@@ -680,6 +680,31 @@ export function sarifCoverageGapNotification(gap) {
|
|
|
680
680
|
},
|
|
681
681
|
};
|
|
682
682
|
}
|
|
683
|
+
// The accepted counterpart: the same bounded sample, saying the state is a
|
|
684
|
+
// recorded acceptance (`coverage.unowned`) rather than an open question —
|
|
685
|
+
// still a warning, because an accepted hole is still a hole, and this face
|
|
686
|
+
// must not be the one that stops saying so.
|
|
687
|
+
if (gap.kind === "accepted-unowned-files") {
|
|
688
|
+
const files = gap.files ?? [];
|
|
689
|
+
const languages = gap.languages ?? [];
|
|
690
|
+
const spans = languages.length > 0 ? ` (${languages.join(", ")})` : "";
|
|
691
|
+
const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
|
|
692
|
+
const remaining = files.length - shown.length;
|
|
693
|
+
const listed =
|
|
694
|
+
shown.length > 0
|
|
695
|
+
? `: ${shown.join(", ")}${remaining > 0 ? `, and ${remaining} more` : ""}`
|
|
696
|
+
: "";
|
|
697
|
+
return {
|
|
698
|
+
level: "warning",
|
|
699
|
+
message: {
|
|
700
|
+
text:
|
|
701
|
+
`${files.length} tracked analyzable file${files.length === 1 ? "" : "s"}${spans} ` +
|
|
702
|
+
`owned by no project — accepted as coverage holes by the policy's coverage.unowned, ` +
|
|
703
|
+
`so no boundary verdict in this run covers ` +
|
|
704
|
+
`${files.length === 1 ? "it" : "them"}${listed}`,
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
}
|
|
683
708
|
return {
|
|
684
709
|
level: "warning",
|
|
685
710
|
message: {
|
|
@@ -931,6 +956,261 @@ export function buildSarifLog({
|
|
|
931
956
|
};
|
|
932
957
|
}
|
|
933
958
|
|
|
959
|
+
/**
|
|
960
|
+
* One introduced boundary-violation entry, at one of its head sites, as a
|
|
961
|
+
* SARIF result — the `delta` verb's rendering of the same rule catalogue
|
|
962
|
+
* `sarifResult` resolves into.
|
|
963
|
+
*
|
|
964
|
+
* `ruleId` is the entry's `messageId`, spelled exactly, so a delta upload and
|
|
965
|
+
* a `check` upload name the same rule for the same law — no new descriptors,
|
|
966
|
+
* and no second catalogue. The classifier's entry carries no rendered upstream
|
|
967
|
+
* message (`../commands/delta-classify.mjs` keeps identity and evidence, not
|
|
968
|
+
* prose), so the message is composed here from the facts the entry does carry:
|
|
969
|
+
* the classification, both sides' occurrence counts, the edge, and the
|
|
970
|
+
* constraint row — never empty, because GitHub rejects a result whose
|
|
971
|
+
* `message.text` is.
|
|
972
|
+
*
|
|
973
|
+
* One result per HEAD site: an introduced violation exists at head by
|
|
974
|
+
* construction (`headCount > 0`), and the head sites are the lines a reviewer
|
|
975
|
+
* of the change can act on — a base site names code the checkout under review
|
|
976
|
+
* may no longer contain.
|
|
977
|
+
*
|
|
978
|
+
* A waived-introduced entry is still a result (reported, not gating —
|
|
979
|
+
* `../commands/delta.mjs`'s fold), tagged with the same `accepted` vocabulary
|
|
980
|
+
* `sarifResult` uses for a waived violation, so one consumer query covers both
|
|
981
|
+
* verbs' uploads.
|
|
982
|
+
*
|
|
983
|
+
* @param {{messageId: string, sourceProject: string|null, target: string,
|
|
984
|
+
* targetIsSpecifier: boolean, constraint: object|null, baseCount: number,
|
|
985
|
+
* headCount: number, reason?: string, waived?: boolean,
|
|
986
|
+
* waivedBy?: {expiresAt?: string, reason?: string}}} entry One `introduced`
|
|
987
|
+
* entry from `classifyViolations` (`../commands/delta-classify.mjs`).
|
|
988
|
+
* @param {{file: string, line: number, column: number, specifier?: string,
|
|
989
|
+
* kind?: string}} site One of the entry's head sites — 1-based, as the
|
|
990
|
+
* analysis records carry them.
|
|
991
|
+
* @returns {object}
|
|
992
|
+
*/
|
|
993
|
+
export function sarifDeltaResult(entry, site) {
|
|
994
|
+
const target = entry.targetIsSpecifier
|
|
995
|
+
? `specifier ${JSON.stringify(entry.target)}`
|
|
996
|
+
: entry.target;
|
|
997
|
+
const text =
|
|
998
|
+
`Introduced by this change: ${entry.messageId} — ` +
|
|
999
|
+
`from ${entry.sourceProject ?? "(no project)"} to ${target} ` +
|
|
1000
|
+
`(${entry.baseCount} occurrence${entry.baseCount === 1 ? "" : "s"} at base, ` +
|
|
1001
|
+
`${entry.headCount} at head)` +
|
|
1002
|
+
`${entry.reason ? ` — ${entry.reason}` : ""}. ` +
|
|
1003
|
+
`Constraint: ${formatConstraint(entry.constraint)}`;
|
|
1004
|
+
return {
|
|
1005
|
+
ruleId: entry.messageId,
|
|
1006
|
+
ruleIndex: MESSAGE_IDS.indexOf(entry.messageId),
|
|
1007
|
+
level: "error",
|
|
1008
|
+
message: { text },
|
|
1009
|
+
locations: [
|
|
1010
|
+
{
|
|
1011
|
+
physicalLocation: {
|
|
1012
|
+
artifactLocation: { uri: toUriReference(site.file) },
|
|
1013
|
+
region: { startLine: site.line, startColumn: site.column },
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
],
|
|
1017
|
+
properties: {
|
|
1018
|
+
delta: "introduced",
|
|
1019
|
+
baseCount: entry.baseCount,
|
|
1020
|
+
headCount: entry.headCount,
|
|
1021
|
+
sourceProject: entry.sourceProject,
|
|
1022
|
+
target: entry.target,
|
|
1023
|
+
// The same vocabulary `sarifResult` uses for a waived violation: the
|
|
1024
|
+
// result is still an error (an accepted violation is still a violation),
|
|
1025
|
+
// and the properties say why it does not gate.
|
|
1026
|
+
...(entry.waived === true
|
|
1027
|
+
? {
|
|
1028
|
+
accepted: true,
|
|
1029
|
+
acceptedUntil: entry.waivedBy?.expiresAt,
|
|
1030
|
+
acceptedReason: entry.waivedBy?.reason,
|
|
1031
|
+
}
|
|
1032
|
+
: {}),
|
|
1033
|
+
},
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* The `delta` verb's SARIF log: the INTRODUCED buckets as results, everything
|
|
1039
|
+
* the classification could not answer as tool-execution notifications.
|
|
1040
|
+
*
|
|
1041
|
+
* Results are introduced entries only — the classified boundary violations
|
|
1042
|
+
* (`sarifDeltaResult`, waived ones included and tagged) and the introduced
|
|
1043
|
+
* custom-rule findings, the latter through the same `sarifCustomRuleResult`
|
|
1044
|
+
* and end-of-catalogue descriptors `check`'s log uses, so a custom finding
|
|
1045
|
+
* resolves in `sarifRules(customCatalogue)` here exactly as it does there.
|
|
1046
|
+
* Resolved and unchanged entries are deliberately NOT results: this log is
|
|
1047
|
+
* uploaded against the head checkout, and an annotation for a violation the
|
|
1048
|
+
* change resolved would mark code that no longer contains it.
|
|
1049
|
+
*
|
|
1050
|
+
* The notification lane is the load-bearing half. `delta` exits 3 on any
|
|
1051
|
+
* `unknown` entry (`../commands/delta.mjs`'s fold), and every one of those
|
|
1052
|
+
* rides here — a violation, unresolvable-record, or custom-rule item whose
|
|
1053
|
+
* identity could not be stated — so an exit-3 delta can never upload a log
|
|
1054
|
+
* byte-identical to a clean run's, the same guarantee `buildSarifLog` keeps
|
|
1055
|
+
* for `check`'s no-verdict lanes. Coverage notes (policy drift, dirty trees,
|
|
1056
|
+
* skipped or removed custom rules) and introduced unresolvable records ride
|
|
1057
|
+
* the same lane: none is a verdict, and dropping any of them is the silent
|
|
1058
|
+
* direction (`../../../../AGENTS.md`).
|
|
1059
|
+
*
|
|
1060
|
+
* `executionSuccessful` stays `true` on every status — the run completed and
|
|
1061
|
+
* classified; the findings are results, not tool errors — and `columnKind`
|
|
1062
|
+
* states the analyzers' real UTF-16 convention, both for the reasons the
|
|
1063
|
+
* module header gives.
|
|
1064
|
+
*
|
|
1065
|
+
* @param {{delta: {violations: {introduced: object[], resolved?: object[],
|
|
1066
|
+
* unchanged?: object[], unknown: object[]},
|
|
1067
|
+
* unresolvable: {introduced: object[], resolved?: object[],
|
|
1068
|
+
* unchanged?: object[], unknown: object[]},
|
|
1069
|
+
* customRules?: {findings: {introduced: object[], resolved?: object[],
|
|
1070
|
+
* unchanged?: object[], unknown: object[]}}},
|
|
1071
|
+
* coverage: {notes?: string[]},
|
|
1072
|
+
* customCatalogue?: {ruleId: string, rule: string, findingId: string,
|
|
1073
|
+
* message: string}[]}} run The `deltaCommand` result's `delta` and
|
|
1074
|
+
* `coverage`, plus the head-declared custom-rule catalogue
|
|
1075
|
+
* (`../commands/custom-rules.mjs`'s `customRulesForDelta`) — passed
|
|
1076
|
+
* separately because the envelope deliberately does not carry it.
|
|
1077
|
+
* @returns {object} A SARIF 2.1.0 log, ready to `JSON.stringify`.
|
|
1078
|
+
*/
|
|
1079
|
+
export function buildDeltaSarifLog({ delta, coverage, customCatalogue = [] }) {
|
|
1080
|
+
const customRuleIndex = new Map(
|
|
1081
|
+
customCatalogue.map((entry, index) => [entry.ruleId, CUSTOM_RULE_INDEX_BASE + index]),
|
|
1082
|
+
);
|
|
1083
|
+
const customIntroduced = delta.customRules?.findings.introduced ?? [];
|
|
1084
|
+
const results = [
|
|
1085
|
+
...delta.violations.introduced.flatMap((entry) =>
|
|
1086
|
+
entry.headSites.map((site) => sarifDeltaResult(entry, site)),
|
|
1087
|
+
),
|
|
1088
|
+
...customIntroduced.flatMap((entry) =>
|
|
1089
|
+
entry.headSites.map((site) => {
|
|
1090
|
+
const rendered = sarifCustomRuleResult(
|
|
1091
|
+
{
|
|
1092
|
+
id: entry.ruleId,
|
|
1093
|
+
message:
|
|
1094
|
+
`Introduced by this change: custom rule finding ${entry.ruleId} ` +
|
|
1095
|
+
`(${entry.baseCount} occurrence${entry.baseCount === 1 ? "" : "s"} at base, ` +
|
|
1096
|
+
`${entry.headCount} at head)` +
|
|
1097
|
+
`${entry.reason ? ` — ${entry.reason}` : ""}` +
|
|
1098
|
+
`${typeof entry.message === "string" && entry.message !== "" ? `: ${entry.message}` : ""}`,
|
|
1099
|
+
...(typeof site.file === "string" ? { sourceFile: site.file } : {}),
|
|
1100
|
+
...(typeof site.line === "number" ? { line: site.line } : {}),
|
|
1101
|
+
...(typeof site.column === "number" ? { column: site.column } : {}),
|
|
1102
|
+
...(entry.project === null ? {} : { project: entry.project }),
|
|
1103
|
+
},
|
|
1104
|
+
// `?? -1` for the same reason `buildSarifLog` gives: an introduced
|
|
1105
|
+
// finding whose id its own head catalogue does not declare cannot
|
|
1106
|
+
// arrive here, and a regression in that guarantee must be a visibly
|
|
1107
|
+
// broken index rather than an `undefined` GitHub ignores.
|
|
1108
|
+
customRuleIndex.get(entry.ruleId) ?? -1,
|
|
1109
|
+
);
|
|
1110
|
+
return {
|
|
1111
|
+
...rendered,
|
|
1112
|
+
properties: {
|
|
1113
|
+
...(rendered.properties ?? {}),
|
|
1114
|
+
delta: "introduced",
|
|
1115
|
+
baseCount: entry.baseCount,
|
|
1116
|
+
headCount: entry.headCount,
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
}),
|
|
1120
|
+
),
|
|
1121
|
+
];
|
|
1122
|
+
const toolExecutionNotifications = [
|
|
1123
|
+
...delta.violations.unknown.map((entry) => ({
|
|
1124
|
+
level: "warning",
|
|
1125
|
+
message: {
|
|
1126
|
+
text: `delta could not classify a violation — ${entry.reason}. The run reaches no verdict.`,
|
|
1127
|
+
},
|
|
1128
|
+
})),
|
|
1129
|
+
...delta.unresolvable.unknown.map((entry) => ({
|
|
1130
|
+
level: "warning",
|
|
1131
|
+
message: {
|
|
1132
|
+
text:
|
|
1133
|
+
`delta could not classify an unresolvable import site — ${entry.reason}. ` +
|
|
1134
|
+
`The run reaches no verdict.`,
|
|
1135
|
+
},
|
|
1136
|
+
})),
|
|
1137
|
+
...(delta.customRules?.findings.unknown ?? []).map((entry) => ({
|
|
1138
|
+
level: "warning",
|
|
1139
|
+
message: {
|
|
1140
|
+
text:
|
|
1141
|
+
`delta could not classify a custom-rule item` +
|
|
1142
|
+
`${typeof entry.rule === "string" ? ` (rule "${entry.rule}")` : ""} — ` +
|
|
1143
|
+
`${entry.reason}. The run reaches no verdict.`,
|
|
1144
|
+
},
|
|
1145
|
+
})),
|
|
1146
|
+
// Introduced unresolvable records: sites the change added whose target
|
|
1147
|
+
// analysis could not resolve. Never results — no rule reached a verdict
|
|
1148
|
+
// about them — but a change that adds them must not upload the log a
|
|
1149
|
+
// change that adds nothing would.
|
|
1150
|
+
...delta.unresolvable.introduced.map((entry) => ({
|
|
1151
|
+
level: "warning",
|
|
1152
|
+
message: {
|
|
1153
|
+
text:
|
|
1154
|
+
`This change introduces ${entry.headCount - entry.baseCount} unresolvable import ` +
|
|
1155
|
+
`site${entry.headCount - entry.baseCount === 1 ? "" : "s"} for specifier ` +
|
|
1156
|
+
`${JSON.stringify(entry.specifier)} (${entry.kind || "unknown kind"})` +
|
|
1157
|
+
`${entry.sourceProject === null ? "" : ` in ${entry.sourceProject}`} — ` +
|
|
1158
|
+
`no rule reached a verdict about ${entry.headCount - entry.baseCount === 1 ? "it" : "them"}`,
|
|
1159
|
+
},
|
|
1160
|
+
locations: (entry.headSites ?? [])
|
|
1161
|
+
.filter((site) => typeof site.file === "string")
|
|
1162
|
+
.map((site) => ({
|
|
1163
|
+
physicalLocation: {
|
|
1164
|
+
artifactLocation: { uri: toUriReference(site.file) },
|
|
1165
|
+
...(typeof site.line === "number"
|
|
1166
|
+
? {
|
|
1167
|
+
region: {
|
|
1168
|
+
startLine: site.line,
|
|
1169
|
+
...(typeof site.column === "number" ? { startColumn: site.column } : {}),
|
|
1170
|
+
},
|
|
1171
|
+
}
|
|
1172
|
+
: {}),
|
|
1173
|
+
},
|
|
1174
|
+
})),
|
|
1175
|
+
})),
|
|
1176
|
+
...(coverage.notes ?? []).map((note) => ({
|
|
1177
|
+
level: "warning",
|
|
1178
|
+
message: { text: `Coverage note: ${note}` },
|
|
1179
|
+
})),
|
|
1180
|
+
];
|
|
1181
|
+
return {
|
|
1182
|
+
$schema: SARIF_SCHEMA,
|
|
1183
|
+
version: SARIF_VERSION,
|
|
1184
|
+
runs: [
|
|
1185
|
+
{
|
|
1186
|
+
tool: { driver: { name: "archkeep", rules: sarifRules(customCatalogue) } },
|
|
1187
|
+
columnKind: "utf16CodeUnits",
|
|
1188
|
+
results,
|
|
1189
|
+
invocations: [
|
|
1190
|
+
{
|
|
1191
|
+
// True even on exit 1 or 3: the classification completed and said
|
|
1192
|
+
// what it could not answer — the same reasoning `buildSarifLog`
|
|
1193
|
+
// states for `check`.
|
|
1194
|
+
executionSuccessful: true,
|
|
1195
|
+
toolExecutionNotifications,
|
|
1196
|
+
},
|
|
1197
|
+
],
|
|
1198
|
+
},
|
|
1199
|
+
],
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* The delta SARIF log as the bytes to write — pretty-printed with a trailing
|
|
1205
|
+
* newline, the same presentation `formatSarif` gives `check`'s log.
|
|
1206
|
+
*
|
|
1207
|
+
* @param {Parameters<typeof buildDeltaSarifLog>[0]} run
|
|
1208
|
+
* @returns {string}
|
|
1209
|
+
*/
|
|
1210
|
+
export function formatDeltaSarif(run) {
|
|
1211
|
+
return `${JSON.stringify(buildDeltaSarifLog(run), null, 2)}\n`;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
934
1214
|
/**
|
|
935
1215
|
* The SARIF log as the bytes to write — pretty-printed with a trailing newline,
|
|
936
1216
|
* so a file that lands in a diff or a log stays readable.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatters two snapshot-transition reports share — `history-text.mjs` and
|
|
3
|
+
* `evolution-text.mjs` render the same transition shape (a graph diff, notes,
|
|
4
|
+
* and a short kind label), and a second copy of these helpers is where the
|
|
5
|
+
* two renders would drift: one day "code drift" means one thing in `history`
|
|
6
|
+
* and another in `evolution`, and no gate compares rendered prose.
|
|
7
|
+
*
|
|
8
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
9
|
+
* wearing a formatter's name (`../README.md`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Neutralises control and terminal-escape sequences in a name or value before
|
|
14
|
+
* it is printed, so a crafted project/tag/edge name cannot inject escape
|
|
15
|
+
* sequences into a consumer's terminal (`SECURITY.md`). Real project names are
|
|
16
|
+
* ordinary characters and pass through untouched; only C0 control characters
|
|
17
|
+
* (which includes the ESC byte) and DEL become visible escapes.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function sanitize(text) {
|
|
23
|
+
// eslint-disable-next-line no-control-regex
|
|
24
|
+
return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
|
|
25
|
+
if (c === "\n") return "\\n";
|
|
26
|
+
if (c === "\t") return "\\t";
|
|
27
|
+
if (c === "\r") return "\\r";
|
|
28
|
+
return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One project as a line, same shape as `graph-text.mjs`.
|
|
34
|
+
*
|
|
35
|
+
* @param {{name: string, root: string, tags: string[]}} project
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function formatProject(project) {
|
|
39
|
+
const tags =
|
|
40
|
+
project.tags.length > 0 ? ` [${project.tags.map((t) => sanitize(t)).join(", ")}]` : "";
|
|
41
|
+
return ` ${sanitize(project.name)} ${sanitize(project.root)}${tags}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One edge as a line, same shape as `graph-text.mjs`.
|
|
46
|
+
*
|
|
47
|
+
* @param {{source: string, target: string, type: string}} edge
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function formatEdge(edge) {
|
|
51
|
+
return ` ${sanitize(edge.source)} → ${sanitize(edge.target)} (${sanitize(edge.type)})`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One metadata change as a line, same shape as `diff-text.mjs`.
|
|
56
|
+
*
|
|
57
|
+
* @param {{field: string, baseline: *, head: *}} change
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function formatChange(change) {
|
|
61
|
+
const formatValue = (v) => {
|
|
62
|
+
if (Array.isArray(v)) return v.length > 0 ? v.map((x) => sanitize(x)).join(", ") : "(none)";
|
|
63
|
+
if (v === null || v === undefined) return "(none)";
|
|
64
|
+
return sanitize(String(v));
|
|
65
|
+
};
|
|
66
|
+
return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How the architecture actually changed between two snapshots: the added and
|
|
71
|
+
* removed projects and edges rendered as one line each. Changed projects
|
|
72
|
+
* render their changed fields beneath the project line, like `diff`.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} changes The `computeDiff` payload.
|
|
75
|
+
* @returns {string[]}
|
|
76
|
+
*/
|
|
77
|
+
export function formatChanges(changes) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
if (changes.addedProjects.length > 0) {
|
|
80
|
+
const word = changes.addedProjects.length === 1 ? "project" : "projects";
|
|
81
|
+
lines.push(`+ ${changes.addedProjects.length} added ${word}`);
|
|
82
|
+
for (const project of changes.addedProjects) lines.push(formatProject(project));
|
|
83
|
+
}
|
|
84
|
+
if (changes.removedProjects.length > 0) {
|
|
85
|
+
const word = changes.removedProjects.length === 1 ? "project" : "projects";
|
|
86
|
+
lines.push(`- ${changes.removedProjects.length} removed ${word}`);
|
|
87
|
+
for (const project of changes.removedProjects) lines.push(formatProject(project));
|
|
88
|
+
}
|
|
89
|
+
if (changes.changedProjects.length > 0) {
|
|
90
|
+
const word = changes.changedProjects.length === 1 ? "project" : "projects";
|
|
91
|
+
lines.push(`~ ${changes.changedProjects.length} changed ${word}`);
|
|
92
|
+
for (const project of changes.changedProjects) {
|
|
93
|
+
lines.push(` ${project.name}`);
|
|
94
|
+
for (const change of project.changes) lines.push(formatChange(change));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (changes.addedEdges.length > 0) {
|
|
98
|
+
const word = changes.addedEdges.length === 1 ? "edge" : "edges";
|
|
99
|
+
lines.push(`+ ${changes.addedEdges.length} added ${word}`);
|
|
100
|
+
for (const edge of changes.addedEdges) lines.push(formatEdge(edge));
|
|
101
|
+
}
|
|
102
|
+
if (changes.removedEdges.length > 0) {
|
|
103
|
+
const word = changes.removedEdges.length === 1 ? "edge" : "edges";
|
|
104
|
+
lines.push(`- ${changes.removedEdges.length} removed ${word}`);
|
|
105
|
+
for (const edge of changes.removedEdges) lines.push(formatEdge(edge));
|
|
106
|
+
}
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Classifies one transition into the short "kind" a reader skims for.
|
|
112
|
+
*
|
|
113
|
+
* @param {{architectureChanged: boolean, codeDrift: boolean, policyChanged: boolean|null,
|
|
114
|
+
* providerChanged: boolean}} transition
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function transitionKind(transition) {
|
|
118
|
+
if (transition.architectureChanged) return "architecture";
|
|
119
|
+
if (transition.providerChanged) return "provider";
|
|
120
|
+
if (transition.policyChanged === true) return "policy";
|
|
121
|
+
if (transition.codeDrift) return "code drift";
|
|
122
|
+
return "unchanged";
|
|
123
|
+
}
|
package/src/report/text.mjs
CHANGED
|
@@ -588,6 +588,7 @@ const UNOWNED_SAMPLE_LIMIT = 10;
|
|
|
588
588
|
function formatCoverageGap(gap) {
|
|
589
589
|
if (gap.kind === "unregistered-plugin") return formatUnregisteredPluginGap(gap);
|
|
590
590
|
if (gap.kind === "unowned-files") return formatUnownedFilesGap(gap);
|
|
591
|
+
if (gap.kind === "accepted-unowned-files") return formatAcceptedUnownedFilesGap(gap);
|
|
591
592
|
return `⚠ coverage gap "${gap.kind}" — part of this workspace is outside what this run covered`;
|
|
592
593
|
}
|
|
593
594
|
|
|
@@ -655,6 +656,41 @@ function formatUnownedFilesGap(gap) {
|
|
|
655
656
|
);
|
|
656
657
|
}
|
|
657
658
|
|
|
659
|
+
/**
|
|
660
|
+
* The accepted counterpart of the gap above: unowned files the boundary
|
|
661
|
+
* policy's `coverage.unowned` rows accept (`../commands/check.mjs`,
|
|
662
|
+
* `../commands/coverage-acceptance.mjs`). An accepted hole is still a hole,
|
|
663
|
+
* so the section renders on every run the acceptance is in force, with the
|
|
664
|
+
* same bounded sample the warning uses — what it no longer does is ask the
|
|
665
|
+
* reader a question someone already answered, which is the warning's job.
|
|
666
|
+
* The reasons live on the accepting rows, and `archkeep waivers` is the
|
|
667
|
+
* surface that names each row with its reason and current coverage; this
|
|
668
|
+
* face points there rather than restating them.
|
|
669
|
+
*
|
|
670
|
+
* @param {{languages?: string[], files: string[]}} gap
|
|
671
|
+
* @returns {string}
|
|
672
|
+
*/
|
|
673
|
+
function formatAcceptedUnownedFilesGap(gap) {
|
|
674
|
+
const files = gap.files ?? [];
|
|
675
|
+
const count = files.length;
|
|
676
|
+
const languages = gap.languages ?? [];
|
|
677
|
+
const spans = languages.length > 0 ? ` (${languages.join(", ")})` : "";
|
|
678
|
+
const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
|
|
679
|
+
const remaining = count - shown.length;
|
|
680
|
+
const lines = shown.map((file) => `${CONTINUED}${file}`);
|
|
681
|
+
if (remaining > 0) {
|
|
682
|
+
lines.push(`${CONTINUED}… and ${remaining} more — the full list is in --format json`);
|
|
683
|
+
}
|
|
684
|
+
const them = count === 1 ? "it" : "them";
|
|
685
|
+
return (
|
|
686
|
+
`⚠ ${count} tracked analyzable file${count === 1 ? "" : "s"}${spans} ` +
|
|
687
|
+
`owned by no project — accepted as coverage holes by the policy's coverage.unowned, ` +
|
|
688
|
+
`so no boundary verdict covers ${them}\n` +
|
|
689
|
+
`${lines.join("\n")}\n` +
|
|
690
|
+
`${DETAIL}each accepting row's reason: archkeep waivers`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
658
694
|
/**
|
|
659
695
|
* The policy-identity line — which law this run enforced — rendered FIRST,
|
|
660
696
|
* ahead of every verdict below it: a reader has to know WHICH law produced a
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `trajectory` command: the aggregate signals
|
|
3
|
+
* across a snapshot history, with what each number is a claim about.
|
|
4
|
+
*
|
|
5
|
+
* Every line states counts, never judgments — the report has no "better", no
|
|
6
|
+
* score, no direction adjective. The header names the observation basis (one
|
|
7
|
+
* observation is one stored graph snapshot, not a commit or a day), an
|
|
8
|
+
* insufficient history says so instead of printing zeros, and the disclosures
|
|
9
|
+
* line prints even when every count is zero, so a reader can tell "nothing
|
|
10
|
+
* was incomparable" from "the report forgot to say".
|
|
11
|
+
*
|
|
12
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
13
|
+
* wearing a formatter's name (`../README.md`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Neutralises control and terminal-escape sequences before anything reaches
|
|
18
|
+
* the terminal (`SECURITY.md`) — the same sanitation every other renderer
|
|
19
|
+
* applies. Only paths and snapshot filenames are printed here; project and
|
|
20
|
+
* edge identities stay aggregated precisely so nothing name-shaped needs to.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} text
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function sanitize(text) {
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
|
|
28
|
+
if (c === "\n") return "\\n";
|
|
29
|
+
if (c === "\t") return "\\t";
|
|
30
|
+
if (c === "\r") return "\\r";
|
|
31
|
+
return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A delta with its sign, so +2 / -1 / 0 read as movement rather as bare
|
|
37
|
+
* magnitudes. Zero prints unsigned — it is the absence of movement, not a
|
|
38
|
+
* positive one.
|
|
39
|
+
*
|
|
40
|
+
* @param {number|null} value
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function signed(value) {
|
|
44
|
+
if (value === null) return "n/a";
|
|
45
|
+
if (value > 0) return `+${value}`;
|
|
46
|
+
return `${value}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A count that is either established or explicitly unavailable. `null`
|
|
51
|
+
* renders as `n/a` beside the reason the header already stated — never as a
|
|
52
|
+
* zero that would claim a measurement.
|
|
53
|
+
*
|
|
54
|
+
* @param {number|null} value
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function counted(value) {
|
|
58
|
+
return value === null ? "n/a" : `${value}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One structural axis as one line: endpoints, then events, then persistence.
|
|
63
|
+
*
|
|
64
|
+
* @param {{first: number, current: number, delta: number|null,
|
|
65
|
+
* addedEvents: number|null, removedEvents: number|null,
|
|
66
|
+
* changedEvents: number|null, introduced: number|null, resolved: number|null,
|
|
67
|
+
* persistent: number|null}} axis
|
|
68
|
+
* @param {boolean} withChanged Whether the axis carries a changed-event count
|
|
69
|
+
* (projects do; edges do not — a type flip is remove+add under the triple
|
|
70
|
+
* identity).
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function formatAxis(axis, withChanged) {
|
|
74
|
+
const parts = [
|
|
75
|
+
`first ${axis.first}`,
|
|
76
|
+
`current ${axis.current}`,
|
|
77
|
+
`delta ${signed(axis.delta)}`,
|
|
78
|
+
`added ${counted(axis.addedEvents)}`,
|
|
79
|
+
`removed ${counted(axis.removedEvents)}`,
|
|
80
|
+
];
|
|
81
|
+
if (withChanged) parts.push(`changed ${counted(axis.changedEvents)}`);
|
|
82
|
+
parts.push(
|
|
83
|
+
`introduced ${counted(axis.introduced)}`,
|
|
84
|
+
`resolved ${counted(axis.resolved)}`,
|
|
85
|
+
`persistent ${counted(axis.persistent)}`,
|
|
86
|
+
);
|
|
87
|
+
return parts.join(" · ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The whole trajectory report.
|
|
92
|
+
*
|
|
93
|
+
* @param {{trajectory: {dir: string, observations: {count: number, basis: string,
|
|
94
|
+
* first: string|null, last: string|null, withProvenance: number,
|
|
95
|
+
* dirtyProvenance: number}, available: boolean, unavailableReason: string|null,
|
|
96
|
+
* transitions: {count: number, architecture: number, policy: number,
|
|
97
|
+
* provider: number, codeDrift: number, incomparable: number, unchanged: number},
|
|
98
|
+
* disclosures: {policyOneSided: number, provenanceOneSided: number, crossRepo: number},
|
|
99
|
+
* projects: object, edges: object}, coverage: object}} input
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function formatTrajectoryReport({ trajectory, coverage }) {
|
|
103
|
+
const sections = [];
|
|
104
|
+
|
|
105
|
+
const observations = trajectory.observations;
|
|
106
|
+
sections.push(`trajectory ${trajectory.dir}`);
|
|
107
|
+
sections.push(
|
|
108
|
+
`${observations.count} observation${observations.count === 1 ? "" : "s"} ` +
|
|
109
|
+
`(${observations.basis}), ${trajectory.transitions.count} transition${
|
|
110
|
+
trajectory.transitions.count === 1 ? "" : "s"
|
|
111
|
+
}`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (!trajectory.available) {
|
|
115
|
+
// Named, not implied: a one-snapshot history cannot show movement, and
|
|
116
|
+
// every derived number below stays n/a rather than reading as a zero.
|
|
117
|
+
sections.push(
|
|
118
|
+
`✖ ${trajectory.unavailableReason}: a trajectory needs at least two observations — ` +
|
|
119
|
+
"derived values are unavailable, not zero",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const t = trajectory.transitions;
|
|
124
|
+
sections.push(
|
|
125
|
+
`signals architecture ${t.architecture} · policy ${t.policy} · provider ${t.provider} · ` +
|
|
126
|
+
`code drift ${t.codeDrift} · incomparable ${t.incomparable} · unchanged ${t.unchanged}`,
|
|
127
|
+
);
|
|
128
|
+
sections.push(`projects ${formatAxis(trajectory.projects, true)}`);
|
|
129
|
+
sections.push(`edges ${formatAxis(trajectory.edges, false)}`);
|
|
130
|
+
|
|
131
|
+
const d = trajectory.disclosures;
|
|
132
|
+
sections.push(
|
|
133
|
+
`disclosures policy incomparable ${d.policyOneSided} · provenance incomparable ` +
|
|
134
|
+
`${d.provenanceOneSided} · cross-repo ${d.crossRepo} · ` +
|
|
135
|
+
`dirty captures ${observations.dirtyProvenance} · with provenance ${observations.withProvenance}`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
for (const note of coverage.notes) {
|
|
139
|
+
sections.push(sanitize(note));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return sections.join("\n");
|
|
143
|
+
}
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* @param {{waivers: object[], covered: number, expired: number, stale: number,
|
|
19
|
-
* suppressions: object[], suppressed: number
|
|
19
|
+
* suppressions: object[], suppressed: number,
|
|
20
|
+
* unownedAcceptances?: {path: string, reason: string, covered: number}[]}} result
|
|
21
|
+
* `unownedAcceptances` is present exactly when the policy declares
|
|
22
|
+
* `coverage.unowned` (`../commands/waivers.mjs`) — the third surface, and
|
|
23
|
+
* the one the acceptances' reasons live on.
|
|
20
24
|
* @returns {string}
|
|
21
25
|
*/
|
|
22
26
|
export function formatWaiversReport({
|
|
@@ -26,10 +30,17 @@ export function formatWaiversReport({
|
|
|
26
30
|
stale,
|
|
27
31
|
suppressions,
|
|
28
32
|
suppressed,
|
|
33
|
+
unownedAcceptances,
|
|
29
34
|
}) {
|
|
30
35
|
const sections = [];
|
|
36
|
+
const acceptances = unownedAcceptances ?? [];
|
|
31
37
|
|
|
32
|
-
if (waivers.length === 0 && suppressions.length === 0) {
|
|
38
|
+
if (waivers.length === 0 && suppressions.length === 0 && acceptances.length === 0) {
|
|
39
|
+
// The all-empty claim may only be made when all THREE surfaces were
|
|
40
|
+
// measured and found empty — a declared `coverage.unowned` row is an
|
|
41
|
+
// acceptance on the table exactly as a suppression is, and this line
|
|
42
|
+
// reading "nothing is accepted" over one would be the module header's
|
|
43
|
+
// defect on a third table.
|
|
33
44
|
return `no waivers — every boundary is enforced, nothing is being accepted temporarily or permanently`;
|
|
34
45
|
}
|
|
35
46
|
|
|
@@ -96,5 +107,27 @@ export function formatWaiversReport({
|
|
|
96
107
|
}
|
|
97
108
|
}
|
|
98
109
|
|
|
110
|
+
if (acceptances.length > 0) {
|
|
111
|
+
// The coverage half of the table: each row accepts unowned FILES rather
|
|
112
|
+
// than a verdict — `check` still states the files as an accepted
|
|
113
|
+
// coverage gap, and this section is where their reasons live
|
|
114
|
+
// (`../commands/coverage-acceptance.mjs`). A row covering nothing is
|
|
115
|
+
// named the way a stale waiver is; `check` refuses it outright.
|
|
116
|
+
const totalCovered = acceptances.reduce((sum, row) => sum + row.covered, 0);
|
|
117
|
+
sections.push(
|
|
118
|
+
`${acceptances.length} coverage acceptance${acceptances.length === 1 ? "" : "s"} ` +
|
|
119
|
+
`(coverage.unowned) on the table — currently accepting ${totalCovered} unowned ` +
|
|
120
|
+
`file${totalCovered === 1 ? "" : "s"} as recorded coverage holes`,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
for (const row of acceptances) {
|
|
124
|
+
const coverage =
|
|
125
|
+
row.covered === 0
|
|
126
|
+
? "covers no unowned file right now — the files it accepted may be owned by a project now"
|
|
127
|
+
: `currently covers ${row.covered} unowned file${row.covered === 1 ? "" : "s"}`;
|
|
128
|
+
sections.push([`- ${row.path}: ${coverage}`, ` reason: ${row.reason}`].join("\n"));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
99
132
|
return sections.join("\n\n");
|
|
100
133
|
}
|