@ecoma-io/archkeep 0.14.0 → 0.16.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 +11 -5
- package/cli.mjs +571 -61
- package/commands.mjs +57 -0
- package/lsp.mjs +15 -2
- package/package.json +8 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +485 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +289 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +11 -3
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +124 -16
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/path-util.mjs +40 -0
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/tsconfig-paths.mjs +3 -2
- package/src/workspace.mjs +115 -23
package/src/report/sarif.mjs
CHANGED
|
@@ -956,6 +956,261 @@ export function buildSarifLog({
|
|
|
956
956
|
};
|
|
957
957
|
}
|
|
958
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
|
+
|
|
959
1214
|
/**
|
|
960
1215
|
* The SARIF log as the bytes to write — pretty-printed with a trailing newline,
|
|
961
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/rules/index.mjs
CHANGED
|
@@ -176,13 +176,18 @@ const isProjectGraphProjectNode = (node) =>
|
|
|
176
176
|
*/
|
|
177
177
|
function spellingOf(site) {
|
|
178
178
|
const spelling = site.spelling;
|
|
179
|
-
if (
|
|
179
|
+
if (
|
|
180
|
+
typeof spelling?.path !== "boolean" ||
|
|
181
|
+
typeof spelling?.relative !== "boolean" ||
|
|
182
|
+
typeof spelling?.namesOnly !== "boolean"
|
|
183
|
+
) {
|
|
180
184
|
throw new Error(
|
|
181
185
|
`archkeep: ${site.sourceFile}:${site.line}:${site.column} imports ` +
|
|
182
186
|
`'${site.specifier}' in a record carrying no \`spelling\` — the analysis contract ` +
|
|
183
|
-
`requires \`{ path, relative }\` on every import site, because whether a
|
|
184
|
-
`is a path
|
|
185
|
-
`only the analyzer
|
|
187
|
+
`requires \`{ path, relative, namesOnly }\` on every import site, because whether a ` +
|
|
188
|
+
`specifier is a path, whether it stays inside its own project, and whether its ` +
|
|
189
|
+
`language has any path spelling at all are per-language questions only the analyzer ` +
|
|
190
|
+
`can answer. See src/analysis/contract.md.`,
|
|
186
191
|
);
|
|
187
192
|
}
|
|
188
193
|
return spelling;
|
|
@@ -585,8 +590,18 @@ function* candidateGroupsFor(site, ctx) {
|
|
|
585
590
|
if (!sourceProject) return;
|
|
586
591
|
|
|
587
592
|
// Relative and absolute paths are judged on their TEXT, before any resolution:
|
|
588
|
-
// the projects can be correct and the spelling still be the violation.
|
|
589
|
-
|
|
593
|
+
// the projects can be correct and the spelling still be the violation. The
|
|
594
|
+
// absolute half is a JavaScript-family convention — a bare `libs/x` deep
|
|
595
|
+
// import, a `/libs/x` absolute path — so it stands down entirely where the
|
|
596
|
+
// analyzer declared the language has no path spelling at all
|
|
597
|
+
// (`spelling.namesOnly`): a Go module path or a C# namespace beginning
|
|
598
|
+
// `libs/` is a name, and the name is the only spelling the language has
|
|
599
|
+
// (#376). The edge such an import resolves to is still judged by every check
|
|
600
|
+
// below — only this spelling check stands down, so gating it too broadly
|
|
601
|
+
// (on `spelling.path`, which is false for the bare JS form too) would trade
|
|
602
|
+
// this loud bug for a silent one against ESLint.
|
|
603
|
+
const absoluteIntoAnotherProject =
|
|
604
|
+
!spellingOf(site).namesOnly && isAbsoluteImportIntoAnotherProject(imp, ctx.workspaceLayout);
|
|
590
605
|
let targetProject = absoluteIntoAnotherProject
|
|
591
606
|
? graph.nodes[findProjectForPath(imp, mappings)]
|
|
592
607
|
: graph.nodes[getTargetProjectBasedOnRelativeImport(imp, site.sourceFile, mappings)];
|
|
@@ -140,6 +140,8 @@ export function getPath(reach, graph, sourceProjectName, targetProjectName) {
|
|
|
140
140
|
if (current === targetProjectName) break;
|
|
141
141
|
if (!adjList[current]) break;
|
|
142
142
|
adjList[current]
|
|
143
|
+
.slice()
|
|
144
|
+
.sort()
|
|
143
145
|
.filter((adj) => visited.indexOf(adj) === -1)
|
|
144
146
|
.filter((adj) => matrix[adj]?.[targetProjectName])
|
|
145
147
|
.forEach((adj) => {
|
package/src/rules/tags.mjs
CHANGED
|
@@ -165,11 +165,13 @@ export function orphanedNotDependOnTags(depConstraints, graph) {
|
|
|
165
165
|
* are what the message prints so a reader can see the hop that did it.
|
|
166
166
|
*/
|
|
167
167
|
export function findDependenciesWithTags(targetProject, tags, graph, reach) {
|
|
168
|
-
const reachable = Object.keys(graph.nodes)
|
|
169
|
-
(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
const reachable = Object.keys(graph.nodes)
|
|
169
|
+
.sort()
|
|
170
|
+
.filter(
|
|
171
|
+
(projectName) =>
|
|
172
|
+
pathExists(reach, targetProject.name, projectName) &&
|
|
173
|
+
tags.some((tag) => hasTag(graph.nodes[projectName], tag)),
|
|
174
|
+
);
|
|
173
175
|
return reachable.map((project) =>
|
|
174
176
|
targetProject.name === project
|
|
175
177
|
? [targetProject]
|
package/src/rules/topology.mjs
CHANGED
|
@@ -254,13 +254,15 @@ export function circularViolation({
|
|
|
254
254
|
targetProjectName: targetProject.name,
|
|
255
255
|
path: circularPath.reduce((acc, v) => `${acc} -> ${v.name}`, sourceProject.name),
|
|
256
256
|
filePaths: circularFilePath
|
|
257
|
+
.map((files) => files.filter((f) => typeof f === "string"))
|
|
257
258
|
.map((files) =>
|
|
258
259
|
files.length > 1
|
|
259
260
|
? `[${files.map((f) => `\n${spacer}${spacer}${f}`).join(",")}\n${spacer}]`
|
|
260
261
|
: // Upstream indexes `files[0]` unguarded, printing `undefined` for a
|
|
261
|
-
// hop with no file to blame. Ours can legitimately have none
|
|
262
|
-
//
|
|
263
|
-
|
|
262
|
+
// hop with no file to blame. Ours can legitimately have none —
|
|
263
|
+
// a manifest-declared edge carries no import site — and a blank
|
|
264
|
+
// bullet names nothing, so the hop says what it is instead.
|
|
265
|
+
(files[0] ?? "(no source file — a manifest declares this dependency)"),
|
|
264
266
|
)
|
|
265
267
|
.reduce((acc, files) => `${acc}\n- ${files}`, `- ${sourceFile}`),
|
|
266
268
|
},
|
package/src/tsconfig-paths.mjs
CHANGED
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
* says (`tsconfigPathsFacts` there).
|
|
78
78
|
*/
|
|
79
79
|
import { posix } from "node:path";
|
|
80
|
+
import { stripTrailingSlashes } from "./path-util.mjs";
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* What a hygiene finding means — one entry per `messageId`, the arrangement
|
|
@@ -116,7 +117,7 @@ function probeDirectory(target, base, root) {
|
|
|
116
117
|
// candidates live in its parent.
|
|
117
118
|
const dir =
|
|
118
119
|
prefix === "" || prefix.endsWith("/")
|
|
119
|
-
? joined
|
|
120
|
+
? stripTrailingSlashes(joined) || "/"
|
|
120
121
|
: posix.dirname(joined);
|
|
121
122
|
if (dir === root) return "";
|
|
122
123
|
return dir.startsWith(`${root}/`) ? dir.slice(root.length + 1) : null;
|
|
@@ -144,7 +145,7 @@ function probeDirectory(target, base, root) {
|
|
|
144
145
|
* verdict; `malformed` is for `../cli.mjs` to refuse loudly, never to skip.
|
|
145
146
|
*/
|
|
146
147
|
export function judgeTsconfigPaths({ paths, base, workspaceRoot, tsConfig, directoryExists }) {
|
|
147
|
-
const root = workspaceRoot
|
|
148
|
+
const root = stripTrailingSlashes(workspaceRoot);
|
|
148
149
|
const findings = [];
|
|
149
150
|
const malformed = [];
|
|
150
151
|
let aliases = 0;
|