@ecoma-io/archkeep 0.14.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.
@@ -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
+ }
@@ -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;