@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.
Files changed (68) hide show
  1. package/README.md +11 -5
  2. package/cli.mjs +571 -61
  3. package/commands.mjs +57 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +8 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +485 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +289 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +11 -3
  27. package/src/commands/README.md +52 -1
  28. package/src/commands/change-intent.mjs +461 -0
  29. package/src/commands/change.mjs +612 -0
  30. package/src/commands/check.mjs +2 -1
  31. package/src/commands/context.mjs +124 -16
  32. package/src/commands/custom-rules.mjs +286 -2
  33. package/src/commands/delta-classify.mjs +195 -33
  34. package/src/commands/delta-snapshot.mjs +156 -1
  35. package/src/commands/delta.mjs +142 -17
  36. package/src/commands/diff.mjs +41 -13
  37. package/src/commands/evolution.mjs +473 -0
  38. package/src/commands/history.mjs +130 -103
  39. package/src/commands/policy.mjs +57 -0
  40. package/src/commands/provenance.mjs +7 -44
  41. package/src/commands/rules.mjs +775 -0
  42. package/src/commands/trajectory.mjs +437 -0
  43. package/src/governance/profile-registry.mjs +0 -1
  44. package/src/graph/create-dependencies.mjs +138 -15
  45. package/src/lsp/diagnose.mjs +1 -1
  46. package/src/lsp/server.mjs +97 -1
  47. package/src/lsp/workspace-index.mjs +106 -15
  48. package/src/options.mjs +30 -7
  49. package/src/path-util.mjs +40 -0
  50. package/src/process.mjs +10 -1
  51. package/src/providers/moon.mjs +287 -36
  52. package/src/providers/native/differential.fixtures.mjs +32 -6
  53. package/src/providers/native/discover.mjs +83 -4
  54. package/src/providers/native/graph.mjs +58 -0
  55. package/src/providers/native/model.mjs +59 -1
  56. package/src/report/change-text.mjs +148 -0
  57. package/src/report/delta-text.mjs +82 -1
  58. package/src/report/evolution-text.mjs +83 -0
  59. package/src/report/history-text.mjs +4 -114
  60. package/src/report/sarif.mjs +255 -0
  61. package/src/report/snapshot-text.mjs +123 -0
  62. package/src/report/trajectory-text.mjs +143 -0
  63. package/src/rules/index.mjs +21 -6
  64. package/src/rules/reachability.mjs +2 -0
  65. package/src/rules/tags.mjs +7 -5
  66. package/src/rules/topology.mjs +5 -3
  67. package/src/tsconfig-paths.mjs +3 -2
  68. package/src/workspace.mjs +115 -23
@@ -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
+ }
@@ -176,13 +176,18 @@ const isProjectGraphProjectNode = (node) =>
176
176
  */
177
177
  function spellingOf(site) {
178
178
  const spelling = site.spelling;
179
- if (typeof spelling?.path !== "boolean" || typeof spelling?.relative !== "boolean") {
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 specifier ` +
184
- `is a path and whether it stays inside its own project are per-language questions ` +
185
- `only the analyzer can answer. See src/analysis/contract.md.`,
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
- const absoluteIntoAnotherProject = isAbsoluteImportIntoAnotherProject(imp, ctx.workspaceLayout);
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) => {
@@ -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).filter(
169
- (projectName) =>
170
- pathExists(reach, targetProject.name, projectName) &&
171
- tags.some((tag) => hasTag(graph.nodes[projectName], tag)),
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]
@@ -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, since
262
- // the index only knows the files it was handed.
263
- (files[0] ?? ""),
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
  },
@@ -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.replace(/\/+$/u, "") || "/"
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.replace(/\/+$/u, "");
148
+ const root = stripTrailingSlashes(workspaceRoot);
148
149
  const findings = [];
149
150
  const malformed = [];
150
151
  let aliases = 0;