@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The three things every source-level analyzer needs and none of them should
3
+ * answer twice: where a byte offset lands in the contract's 1-based
4
+ * coordinates, which project owns a workspace-relative path, and a per-run
5
+ * cache for the work that is per-workspace rather than per-file.
6
+ *
7
+ * Peer of `manifest-util.mjs`, which does the same job for the manifest
8
+ * readers. The split is by input: that file parses TOML, this one reads
9
+ * positions and project roots out of sources.
10
+ */
11
+
12
+ /**
13
+ * The one text `lineStartsOf` last built an index for, and that index. One
14
+ * entry, because one entry is the shape of the work: every caller walks ONE
15
+ * file's import sites in a loop, so the file being asked about only changes
16
+ * when the loop ends.
17
+ *
18
+ * Keyed by the string itself. Strings are immutable, so an index built from
19
+ * one text is correct for any text equal to it — there is no stale answer to
20
+ * guard against, only a hit or a miss. What proving that equality costs is
21
+ * the subject of the hit path's own comment below.
22
+ */
23
+ let indexedText = null;
24
+ /** @type {number[]|null} */
25
+ let indexedStarts = null;
26
+
27
+ /**
28
+ * Where every line of `text` begins, as offsets into it: entry `i` is the
29
+ * offset of line `i + 1`, so a 1-based line number reads straight off it and
30
+ * the array is never empty (line 1 starts at 0, in an empty file too).
31
+ *
32
+ * Built by one scan of the text and memoized on the text it was built from,
33
+ * which is what makes `positionAt` below cost a binary search per call
34
+ * instead of a scan. **Treat the result as read-only** — it is shared with
35
+ * every other caller asking about the same text.
36
+ *
37
+ * Measured, before this index existed: a Go file with 8000 import sites cost
38
+ * 1668ms to position (2000 sites cost 108ms, 4000 cost 417ms — four times the
39
+ * time for twice the sites, the signature of the quadratic every one of the
40
+ * three source analyzers was paying), because each `positionAt` rescanned the
41
+ * file from offset 0. Analyzed files are attacker-supplied
42
+ * (`../../../../SECURITY.md`), so that was a denial of service reachable by
43
+ * committing one large generated file.
44
+ *
45
+ * @param {string} text
46
+ * @returns {number[]}
47
+ */
48
+ export function lineStartsOf(text) {
49
+ if (indexedStarts !== null && indexedText === text) {
50
+ // Adopt the caller's string on the way out. `===` on two strings is
51
+ // equality of CONTENT: the memoized index is right for any text equal to
52
+ // the one it was built from, but proving that equality costs a compare of
53
+ // the whole text unless the two are the same reference. Keeping the
54
+ // reference the caller passed makes every later call from that caller a
55
+ // pointer comparison — without it, two equal 116KB texts turned this memo
56
+ // into a 116KB memcmp per lookup, which is the quadratic wearing a hat.
57
+ indexedText = text;
58
+ return indexedStarts;
59
+ }
60
+ const starts = [0];
61
+ for (let i = 0; i < text.length; i++) {
62
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
63
+ }
64
+ indexedText = text;
65
+ indexedStarts = starts;
66
+ return starts;
67
+ }
68
+
69
+ /**
70
+ * Where `offset` lands in `text`, in the contract's coordinates: line and
71
+ * column both 1-based, because that is what an editor diagnostic and a
72
+ * `file:line:column` terminal report want (`contract.md`).
73
+ *
74
+ * Column counts UTF-16 code units from the start of the line, which is what
75
+ * `String.prototype.length`, TypeScript's own `getLineAndCharacterOfPosition`,
76
+ * and the LSP's default position encoding all count. A line break is `\n`;
77
+ * a CRLF file therefore reports the same columns, because the `\r` belongs to
78
+ * the end of the preceding line and never to the start of the next one.
79
+ *
80
+ * The answer is read off `lineStartsOf`'s index by binary search — the
81
+ * greatest line start at or before the offset — rather than by scanning the
82
+ * text, so a file's whole import list costs one scan plus a logarithmic
83
+ * search per site. Every coordinate it can return is one the scanning version
84
+ * returned: the index holds exactly the offsets `lastIndexOf("\n", …) + 1`
85
+ * used to produce, and the line number is that entry's position in it.
86
+ *
87
+ * @param {string} text
88
+ * @param {number} offset Byte offset into `text`; clamped into range rather
89
+ * than trusted, so a caller's arithmetic slip yields a wrong position and
90
+ * not a crash mid-run.
91
+ * @param {number[]} [lineStarts] `text`'s line-start index, for a caller that
92
+ * already holds one. Defaults to the memoized `lineStartsOf(text)`, so a
93
+ * caller that passes nothing pays for the scan once per file rather than
94
+ * once per call — every existing caller is that caller.
95
+ * @returns {{ line: number, column: number }}
96
+ */
97
+ export function positionAt(text, offset, lineStarts = lineStartsOf(text)) {
98
+ const clamped = Math.max(0, Math.min(offset, text.length));
99
+ let low = 0;
100
+ let high = lineStarts.length - 1;
101
+ while (low < high) {
102
+ const mid = (low + high + 1) >> 1;
103
+ if (lineStarts[mid] <= clamped) low = mid;
104
+ else high = mid - 1;
105
+ }
106
+ return { line: low + 1, column: clamped - lineStarts[low] + 1 };
107
+ }
108
+
109
+ /**
110
+ * The project owning `path`, by **longest**-prefix match on project roots.
111
+ *
112
+ * Longest and not first, and the difference is not cosmetic: a project nested
113
+ * inside another's directory (`a/b` inside `a`) matches both roots, and a
114
+ * first-match answer would attribute every one of its files to its parent —
115
+ * every intra-project import would read as a boundary crossing, and every real
116
+ * crossing out of the nested project would vanish into the parent.
117
+ *
118
+ * A project whose root is `""` (a workspace-root project) matches everything,
119
+ * which is correct and still loses to any longer root.
120
+ *
121
+ * @param {{ name: string, root: string }[]} projects
122
+ * @param {string} path Workspace-relative.
123
+ * @returns {{ name: string, root: string }|null}
124
+ */
125
+ export function projectOwning(projects, path) {
126
+ let owner = null;
127
+ for (const project of projects) {
128
+ const root = project.root ?? "";
129
+ if (root !== "" && path !== root && !path.startsWith(`${root}/`)) continue;
130
+ if (owner === null || root.length > owner.root.length) owner = project;
131
+ }
132
+ return owner;
133
+ }
134
+
135
+ /**
136
+ * Wraps `build` so it runs once per `workspace` object instead of once per
137
+ * file.
138
+ *
139
+ * Every analyzer needs something derived from the whole tree before it can
140
+ * resolve one specifier — the module path of each Go project, the crate name
141
+ * of each Rust project, the importable module names of each Python project,
142
+ * TypeScript's parsed compiler options. Rebuilding that per file turns a
143
+ * whole-tree run into an O(files x projects) manifest re-read.
144
+ *
145
+ * Keyed on the workspace object identity through a `WeakMap`, not on
146
+ * `workspace.root`: two runs over the same root with different injected
147
+ * readers (a test's in-memory tree and the real one) must not share an answer,
148
+ * and a caller that builds a fresh workspace per file simply gets no reuse
149
+ * rather than a stale one.
150
+ *
151
+ * @template T
152
+ * @param {(workspace: object) => T} build
153
+ * @returns {(workspace: object) => T}
154
+ */
155
+ export function perWorkspace(build) {
156
+ const cache = new WeakMap();
157
+ return (workspace) => {
158
+ if (cache.has(workspace)) return cache.get(workspace);
159
+ const value = build(workspace);
160
+ cache.set(workspace, value);
161
+ return value;
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Every tracked file in a project whose basename is `basename`, at any depth.
167
+ *
168
+ * The graph resolvers next door look for `<projectRoot>/go.mod` and
169
+ * `<projectRoot>/Cargo.toml` exactly, because an Nx EDGE needs one manifest to
170
+ * stand for one project (`../../AGENTS.md` — one module/crate/package per
171
+ * project root). Analysis attributes a FILE rather than a manifest, so it can
172
+ * be broader without contradicting that: a crate or module nested inside a
173
+ * project still belongs to the project whose directory contains it. A Tauri app
174
+ * keeping its crate in `src-tauri/` — the layout Tauri prescribes — is the case
175
+ * that reaches this.
176
+ *
177
+ * The two therefore disagree about that project, deliberately and in one
178
+ * direction: analysis sees the crate, the graph draws no edge for it. That is
179
+ * the documented modeling limit, surfaced rather than papered over.
180
+ *
181
+ * @param {object} workspace
182
+ * @param {string} projectName
183
+ * @param {string} basename
184
+ * @returns {string[]} Workspace-relative paths.
185
+ */
186
+ export function trackedManifests(workspace, projectName, basename) {
187
+ return workspace
188
+ .filesOf(projectName)
189
+ .filter((file) => file === basename || file.endsWith(`/${basename}`));
190
+ }
191
+
192
+ /** An empty envelope, so a no-op and a clean file are the same shape. */
193
+ export const emptyResult = () => ({ imports: [], failures: [] });
194
+
195
+ /**
196
+ * A failure about a file as a whole rather than one position — the shape
197
+ * `contract.md` fixes for "could not be parsed, read, or resolved", with
198
+ * `line`/`column` explicitly `null` rather than absent.
199
+ *
200
+ * @param {string} sourceFile
201
+ * @param {string} reason
202
+ * @returns {{ sourceFile: string, line: null, column: null, reason: string }}
203
+ */
204
+ export const fileFailure = (sourceFile, reason) => ({
205
+ sourceFile,
206
+ line: null,
207
+ column: null,
208
+ reason,
209
+ });
210
+
211
+ /**
212
+ * Whether a failure means the file has NO verdict at all, rather than one
213
+ * import site inside it having none.
214
+ *
215
+ * The distinction is the difference between a blind spot and a hole. A site
216
+ * failure says "this file was analyzed and one specifier in it is not
217
+ * statically knowable" — a computed `import()` argument, or a literal package
218
+ * import naming no declared project — and the other imports in it were still
219
+ * judged. A whole-file failure says the file was never read, never parsed, had
220
+ * no analyzer that could run, or imported a declared project it could not
221
+ * resolve (a missing workspace edge), so "no violations here" is not a finding
222
+ * about it; it is the absence of one. Only the shape carries this: a null
223
+ * position is what the analysis contract already means by "about the file as a
224
+ * whole", so callers ask here instead of re-testing `line === null` and
225
+ * drifting apart.
226
+ *
227
+ * @param {{ line: number|null }} failure
228
+ * @returns {boolean}
229
+ */
230
+ export const isWholeFileFailure = (failure) => failure.line === null;