@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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;
|