@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,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"profiles": [
|
|
4
|
+
{
|
|
5
|
+
"name": "modular-monolith",
|
|
6
|
+
"block": {
|
|
7
|
+
"depConstraints": [
|
|
8
|
+
{
|
|
9
|
+
"sourceTag": "layer:shared-kernel",
|
|
10
|
+
"onlyDependOnLibsWithTags": ["layer:shared-kernel"],
|
|
11
|
+
"description": "The kernel is what every module is allowed to share, so it may depend on nothing that is not itself shared.",
|
|
12
|
+
"remediation": "A kernel project reaching into a module has made that module part of the kernel; move the code, or move the dependency into the module."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"sourceTag": "layer:module",
|
|
16
|
+
"onlyDependOnLibsWithTags": [
|
|
17
|
+
"layer:module",
|
|
18
|
+
"layer:module-internal",
|
|
19
|
+
"layer:shared-kernel"
|
|
20
|
+
],
|
|
21
|
+
"description": "A module's published surface may integrate with another module's published surface, its own internals, and the kernel.",
|
|
22
|
+
"remediation": "Reach the other module through its layer:module surface rather than through anything it keeps internal."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"sourceTag": "layer:module-internal",
|
|
26
|
+
"onlyDependOnLibsWithTags": ["layer:module-internal", "layer:shared-kernel"],
|
|
27
|
+
"description": "Internals are private implementation. Cross-module integration is the published surface's job, not theirs.",
|
|
28
|
+
"remediation": "Move the integration up into the module's layer:module surface, which is the only part allowed to name another module."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"sourceTag": "layer:app",
|
|
32
|
+
"onlyDependOnLibsWithTags": ["layer:app", "layer:module", "layer:shared-kernel"],
|
|
33
|
+
"description": "The deployable composes modules through their published surfaces; a module's internals are invisible to it.",
|
|
34
|
+
"remediation": "Export what the app needs from the module's layer:module surface instead of importing the internal project."
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"moduleBoundaryOptions": {
|
|
38
|
+
"allow": [],
|
|
39
|
+
"buildTargets": ["build"],
|
|
40
|
+
"enforceBuildableLibDependency": false,
|
|
41
|
+
"allowCircularSelfDependency": false,
|
|
42
|
+
"checkDynamicDependenciesExceptions": [],
|
|
43
|
+
"ignoredCircularDependencies": [],
|
|
44
|
+
"banTransitiveDependencies": false,
|
|
45
|
+
"checkNestedExternalImports": false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "modular-monolith-sealed-kernel",
|
|
51
|
+
"base": "modular-monolith",
|
|
52
|
+
"block": {
|
|
53
|
+
"depConstraints": [
|
|
54
|
+
{
|
|
55
|
+
"sourceTag": "layer:shared-kernel",
|
|
56
|
+
"bannedExternalImports": ["*"],
|
|
57
|
+
"description": "Every module inherits the kernel's third-party dependencies, so the kernel takes none — Node built-ins included.",
|
|
58
|
+
"remediation": "Keep the package in the module that needs it, or expose the capability from the kernel as an interface the module implements."
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"moduleBoundaryOptions": {
|
|
62
|
+
"checkNestedExternalImports": true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "modular-monolith-sealed-modules",
|
|
68
|
+
"base": "modular-monolith",
|
|
69
|
+
"block": {
|
|
70
|
+
"fitness": [
|
|
71
|
+
{
|
|
72
|
+
"name": "module-encapsulation",
|
|
73
|
+
"match": ["tag:layer:module", "tag:layer:module-internal"],
|
|
74
|
+
"condition": {
|
|
75
|
+
"type": "tag-axis-isolation",
|
|
76
|
+
"axis": "module",
|
|
77
|
+
"exempt": ["tag:layer:module"]
|
|
78
|
+
},
|
|
79
|
+
"reason": "The base profile's rows cannot express \"its OWN module's internals\": a tag list names tag values, and every module's internals carry the same layer:module-internal tag. So layer:module-internal reaching ANOTHER module's internals, and a published surface reaching another module's, are both permitted by the four rows above. This function is the half that reads the module: axis relatively — a dependency may leave its module only into something tagged layer:module, which is what a published surface is."
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"profiles": [
|
|
4
|
+
{
|
|
5
|
+
"name": "vertical-slice",
|
|
6
|
+
"block": {
|
|
7
|
+
"depConstraints": [
|
|
8
|
+
{
|
|
9
|
+
"sourceTag": "layer:slice",
|
|
10
|
+
"onlyDependOnLibsWithTags": ["layer:slice", "layer:shared-kernel"],
|
|
11
|
+
"description": "A slice owns its whole stack. The only thing it may reach outside itself is the shared kernel — and which slice it may reach is the feature: axis's question, not this row's.",
|
|
12
|
+
"remediation": "Move the collaborator into this slice, or promote the shared piece into layer:shared-kernel where every slice may see it."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"sourceTag": "layer:shared-kernel",
|
|
16
|
+
"onlyDependOnLibsWithTags": ["layer:shared-kernel"],
|
|
17
|
+
"description": "Everything depends on the kernel, so anything the kernel depends on is shared by every slice whether or not that slice asked.",
|
|
18
|
+
"remediation": "A kernel project reaching into a slice has made that slice part of the kernel; move the code, or move the dependency into the slice."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"sourceTag": "layer:host",
|
|
22
|
+
"onlyDependOnLibsWithTags": ["layer:host", "layer:slice", "layer:shared-kernel"],
|
|
23
|
+
"description": "The composition root is the one project allowed to see more than one slice.",
|
|
24
|
+
"remediation": "Nothing inside a slice may import the host; move the wiring outward rather than the dependency inward."
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"moduleBoundaryOptions": {
|
|
28
|
+
"allow": [],
|
|
29
|
+
"buildTargets": ["build"],
|
|
30
|
+
"enforceBuildableLibDependency": false,
|
|
31
|
+
"allowCircularSelfDependency": false,
|
|
32
|
+
"checkDynamicDependenciesExceptions": [],
|
|
33
|
+
"ignoredCircularDependencies": [],
|
|
34
|
+
"banTransitiveDependencies": false,
|
|
35
|
+
"checkNestedExternalImports": false
|
|
36
|
+
},
|
|
37
|
+
"fitness": [
|
|
38
|
+
{
|
|
39
|
+
"name": "slice-isolation",
|
|
40
|
+
"match": ["tag:layer:slice"],
|
|
41
|
+
"condition": {
|
|
42
|
+
"type": "tag-axis-isolation",
|
|
43
|
+
"axis": "feature"
|
|
44
|
+
},
|
|
45
|
+
"reason": "The row above says a slice may reach layer:slice, which every slice carries — so on its own it permits any slice to reach any other. Feature isolation is a claim about the feature: axis relative to the source, which no tag list can spell: written as constraint rows it is one row per slice, restated every time the tree grows one. This function is the whole of it, in a form that does not change when a slice is added. The kernel and the host carry no feature: tag, so their edges are not this function's subject; a slice that carries none is reported as unjudgeable rather than waved through."
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "vertical-slice-sealed-kernel",
|
|
52
|
+
"base": "vertical-slice",
|
|
53
|
+
"block": {
|
|
54
|
+
"depConstraints": [
|
|
55
|
+
{
|
|
56
|
+
"sourceTag": "layer:shared-kernel",
|
|
57
|
+
"bannedExternalImports": ["*"],
|
|
58
|
+
"description": "Every slice inherits the kernel's third-party dependencies, so the kernel takes none — Node built-ins included.",
|
|
59
|
+
"remediation": "Keep the package in the slice that needs it, or expose the capability from the kernel as an interface the slice implements."
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"moduleBoundaryOptions": {
|
|
63
|
+
"checkNestedExternalImports": true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import-site analysis: the dispatcher, and the types every language analyzer
|
|
3
|
+
* in this directory returns.
|
|
4
|
+
*
|
|
5
|
+
* The reasoning behind each field — why a record is a superset of a graph
|
|
6
|
+
* edge, why an intra-project import is still emitted, why a non-literal
|
|
7
|
+
* `import()` resolves to null instead of being dropped — is in `contract.md`
|
|
8
|
+
* beside this file. The types below are that document's machine-readable half;
|
|
9
|
+
* neither is edited without the other.
|
|
10
|
+
*
|
|
11
|
+
* Two tables decide everything here and nothing else does: an extension names
|
|
12
|
+
* a language (`LANGUAGE_BY_EXTENSION`), a language names an analyzer
|
|
13
|
+
* (`ANALYZER_BY_LANGUAGE`). A language registered in the first table with no
|
|
14
|
+
* entry in the second **throws**, and that is the scaffold staying loud for
|
|
15
|
+
* whichever language arrives next: an empty result would read as "this file
|
|
16
|
+
* imports nothing", which is indistinguishable from "clean" and is the exact
|
|
17
|
+
* fake-green this repository refuses (an empty result is a claim, not a shrug). An extension in neither table is a different case and returns
|
|
18
|
+
* the empty envelope.
|
|
19
|
+
*
|
|
20
|
+
* `LANGUAGE_BY_EXTENSION` and `languageOf` are defined in `./registry.mjs`,
|
|
21
|
+
* not here, and re-exported below — that module's header says why the split
|
|
22
|
+
* exists. Every other import site keeps naming them from this module.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { analyzeGo } from "./go.mjs";
|
|
26
|
+
import { analyzePython } from "./python.mjs";
|
|
27
|
+
import { LANGUAGE_BY_EXTENSION, languageOf } from "./registry.mjs";
|
|
28
|
+
import { analyzeRust } from "./rust.mjs";
|
|
29
|
+
import { emptyResult } from "./source-util.mjs";
|
|
30
|
+
import { analyzeTypeScript } from "./typescript.mjs";
|
|
31
|
+
import { analyzeVue } from "./vue.mjs";
|
|
32
|
+
|
|
33
|
+
export { LANGUAGE_BY_EXTENSION, languageOf };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where a specifier points. `null` on the record itself when the specifier
|
|
37
|
+
* cannot be resolved at all — never a guess, never a dropped record.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {object} ResolvedImport
|
|
40
|
+
* @property {string|null} target Nx project name the specifier resolves into,
|
|
41
|
+
* or `null` when it resolves outside every project.
|
|
42
|
+
* @property {string|null} file Workspace-relative path of the resolved file,
|
|
43
|
+
* or `null` when the resolution stops at a package rather than a file.
|
|
44
|
+
* @property {boolean} external `true` when the specifier resolves outside
|
|
45
|
+
* every project — an npm package, a crate, a Go module, a stdlib module.
|
|
46
|
+
* @property {string|null} packageName The package's own name when `external`
|
|
47
|
+
* (`@tauri-apps/api` for `@tauri-apps/api/window`, not the deep path, so a
|
|
48
|
+
* `bannedExternalImports` glob matches without re-parsing); `null` otherwise.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How the import was written. A dependency that is one edge to the graph is
|
|
53
|
+
* several different rules here, which is why the form is kept.
|
|
54
|
+
*
|
|
55
|
+
* - `static` — a top-level `import`/`require`/`use`/Go import declaration.
|
|
56
|
+
* - `dynamic` — `import()` and its per-language equivalents. Same edge as a
|
|
57
|
+
* static import, different rule (`checkDynamicDependenciesExceptions`).
|
|
58
|
+
* - `type-only` — erased before runtime (`import type`), so it creates no
|
|
59
|
+
* runtime dependency and several constraints do not apply to it.
|
|
60
|
+
* - `re-export` — `export … from`, which imports and re-publishes in one
|
|
61
|
+
* statement; a barrel is built out of these.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {"static" | "dynamic" | "type-only" | "re-export"} ImportKind
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How the specifier is SPELLED, in the two terms the rules ask about — and the
|
|
68
|
+
* analyzer answers, because the answer is per-LANGUAGE and only the analyzer
|
|
69
|
+
* knows which language it read. `contract.md` carries the reasoning.
|
|
70
|
+
*
|
|
71
|
+
* @typedef {object} SpecifierSpelling
|
|
72
|
+
* @property {boolean} path The specifier is a filesystem path — resolvable by
|
|
73
|
+
* path arithmetic against the importing file, and naming no package. True
|
|
74
|
+
* only for the JavaScript family (`.`, `..`, `./x`, `../x`, `/x`); a Go
|
|
75
|
+
* import path, a Rust `use` path and a Python dotted module are names.
|
|
76
|
+
* @property {boolean} relative The specifier reaches inside the importing
|
|
77
|
+
* file's own project without going out through the project's public name —
|
|
78
|
+
* `./x` and `../x` in JavaScript, `crate::`/`self::`/`super::` and a sibling
|
|
79
|
+
* crate target of the same Cargo package in Rust, a leading-dot import in
|
|
80
|
+
* Python. It is the counter-evidence `noSelfCircularDependencies` looks for.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One import site — one import as WRITTEN, not one resolved dependency. The
|
|
85
|
+
* same target imported three times in a file yields three of these.
|
|
86
|
+
*
|
|
87
|
+
* @typedef {object} ImportSite
|
|
88
|
+
* @property {string} sourceFile Workspace-relative path of the importing file.
|
|
89
|
+
* @property {number} line 1-based line of the specifier.
|
|
90
|
+
* @property {number} column 1-based column of the specifier.
|
|
91
|
+
* @property {string} specifier The raw string as written — never normalised,
|
|
92
|
+
* never resolved in place. Five of the rules are decided on this text.
|
|
93
|
+
* @property {ImportKind} kind
|
|
94
|
+
* @property {SpecifierSpelling} spelling Mandatory. The rule engine refuses a
|
|
95
|
+
* record without it rather than reading the specifier itself, which is how it
|
|
96
|
+
* used to answer and how it got two languages wrong.
|
|
97
|
+
* @property {ResolvedImport|null} resolved `null` when unresolvable; the
|
|
98
|
+
* reason then appears in the run's `failures`.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Something the analyzer could not do, recorded rather than thrown. One bad
|
|
103
|
+
* file must not blank a whole run — a report that is empty because the tool
|
|
104
|
+
* crashed and a report that is empty because the tree is clean print the same.
|
|
105
|
+
*
|
|
106
|
+
* @typedef {object} AnalysisFailure
|
|
107
|
+
* @property {string} sourceFile Workspace-relative path of the file involved.
|
|
108
|
+
* @property {number|null} line 1-based, or `null` when the failure is about
|
|
109
|
+
* the file as a whole rather than one position.
|
|
110
|
+
* @property {number|null} column 1-based, or `null`.
|
|
111
|
+
* @property {string} reason Human-readable; written to be read in a report.
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What an analyzer returns. Both arrays are always present and always arrays,
|
|
116
|
+
* so no consumer has to check before iterating.
|
|
117
|
+
*
|
|
118
|
+
* @typedef {object} AnalysisResult
|
|
119
|
+
* @property {ImportSite[]} imports Every import site, in source order.
|
|
120
|
+
* @property {AnalysisFailure[]} failures What could not be parsed, read, or
|
|
121
|
+
* resolved.
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Everything an analyzer may consult beyond the file it was handed. A superset
|
|
126
|
+
* of the `(projects, filesOf, readFile)` triple the graph resolvers next door
|
|
127
|
+
* take, plus the absolute `root` TypeScript's resolver needs.
|
|
128
|
+
*
|
|
129
|
+
* @typedef {object} Workspace
|
|
130
|
+
* @property {string} root Absolute path of the workspace root.
|
|
131
|
+
* @property {{ name: string, root: string }[]} projects Every project, with a
|
|
132
|
+
* workspace-relative root.
|
|
133
|
+
* @property {(projectName: string) => string[]} filesOf A project's tracked
|
|
134
|
+
* files, workspace-relative.
|
|
135
|
+
* @property {(path: string) => string|null} readFile Workspace-relative read;
|
|
136
|
+
* `null` for a file that does not exist or cannot be read.
|
|
137
|
+
* @property {string} [tsConfig] The workspace's shared TypeScript config
|
|
138
|
+
* filename — an Nx convention the plugin's `tsConfig` option can rename.
|
|
139
|
+
* Consulted only by the TypeScript analyzer, and carried here rather than
|
|
140
|
+
* passed beside the workspace because that analyzer caches its parsed
|
|
141
|
+
* compiler options against this object's identity. Omitted, the convention
|
|
142
|
+
* applies.
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The call every language analyzer answers. `text` arrives already read so the
|
|
147
|
+
* caller owns the read strategy and a test can drive an analyzer from a string.
|
|
148
|
+
*
|
|
149
|
+
* @typedef {object} AnalysisRequest
|
|
150
|
+
* @property {string} sourceFile Workspace-relative path of the file to analyze.
|
|
151
|
+
* @property {string} text Its contents.
|
|
152
|
+
* @property {Workspace} workspace Resolution context.
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @callback Analyzer
|
|
157
|
+
* @param {AnalysisRequest} request
|
|
158
|
+
* @returns {AnalysisResult}
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Language → the analyzer that owns it. The second of the dispatcher's two
|
|
163
|
+
* tables, kept separate from the extension registry because the two answer
|
|
164
|
+
* different questions: which language a filename is written in, and whether
|
|
165
|
+
* this tool can read that language yet.
|
|
166
|
+
*/
|
|
167
|
+
const ANALYZER_BY_LANGUAGE = Object.freeze({
|
|
168
|
+
typescript: analyzeTypeScript,
|
|
169
|
+
vue: analyzeVue,
|
|
170
|
+
go: analyzeGo,
|
|
171
|
+
rust: analyzeRust,
|
|
172
|
+
python: analyzePython,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The analyzer owning `language`.
|
|
177
|
+
*
|
|
178
|
+
* Throws for a language `LANGUAGE_BY_EXTENSION` claims but no analyzer
|
|
179
|
+
* implements. That is the one legitimate throw in this layer (`contract.md`):
|
|
180
|
+
* a malformed file is data and becomes a `failure`, but a missing
|
|
181
|
+
* implementation is not an input problem, and reporting an empty result for it
|
|
182
|
+
* would say "this file imports nothing" about every file of that language.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} language
|
|
185
|
+
* @returns {Analyzer}
|
|
186
|
+
* @throws {Error} when no analyzer is registered for `language`.
|
|
187
|
+
*/
|
|
188
|
+
export function analyzerFor(language) {
|
|
189
|
+
const analyzer = ANALYZER_BY_LANGUAGE[language];
|
|
190
|
+
if (analyzer) return analyzer;
|
|
191
|
+
throw new Error(
|
|
192
|
+
`archkeep: no ${language} import analyzer is implemented yet. This is a stub, ` +
|
|
193
|
+
`not a clean result — see src/analysis/contract.md for the record shape an analyzer must return.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Analyzes one file by dispatching on its extension.
|
|
199
|
+
*
|
|
200
|
+
* An extension no language claims is a NO-OP returning the empty envelope, not
|
|
201
|
+
* an error: this is pointed at whatever files a project owns, and those include
|
|
202
|
+
* `README.md`, `project.json`, and a lockfile. Failing on them would make every
|
|
203
|
+
* run red for reasons no rule cares about, and the fix would be an ignore list
|
|
204
|
+
* someone has to keep in sync with the tree.
|
|
205
|
+
*
|
|
206
|
+
* Every analyzer catches its own errors, so a malformed or unreadable file
|
|
207
|
+
* comes back as records plus failures. The only throw that escapes here is
|
|
208
|
+
* `analyzerFor`'s, for a language with no implementation.
|
|
209
|
+
*
|
|
210
|
+
* @param {AnalysisRequest} request
|
|
211
|
+
* @returns {AnalysisResult}
|
|
212
|
+
* @throws {Error} when the file's language has no analyzer implemented yet.
|
|
213
|
+
*/
|
|
214
|
+
export function analyzeFile(request) {
|
|
215
|
+
const language = languageOf(request.sourceFile);
|
|
216
|
+
if (language === null) return emptyResult();
|
|
217
|
+
return analyzerFor(language)(request);
|
|
218
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# The analysis contract
|
|
2
|
+
|
|
3
|
+
What every language analyzer in this directory returns, fixed here so that
|
|
4
|
+
analyzers written independently — Go by one hand, TypeScript by another —
|
|
5
|
+
produce records a single rule engine can read without knowing which one it is
|
|
6
|
+
holding. The JSDoc types in `analyze.mjs` are the machine-readable half of this
|
|
7
|
+
document; this file carries the reasoning, and the two are edited together.
|
|
8
|
+
|
|
9
|
+
An analyzer answers one question: **which import does this file write, where,
|
|
10
|
+
and what does it resolve to.** It judges nothing. Whether an import is allowed
|
|
11
|
+
is `../rules/`'s question, and an analyzer that starts filtering its own output
|
|
12
|
+
has taken a decision away from the layer that owns it.
|
|
13
|
+
|
|
14
|
+
## The record
|
|
15
|
+
|
|
16
|
+
One record per import site — per _written import_, not per resolved
|
|
17
|
+
dependency. A file importing the same project three times yields three records.
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
{
|
|
21
|
+
sourceFile, // workspace-relative path of the importing file
|
|
22
|
+
line, column, // 1-based, for editor diagnostics
|
|
23
|
+
specifier, // the RAW string as written
|
|
24
|
+
kind, // "static" | "dynamic" | "type-only" | "re-export"
|
|
25
|
+
spelling: { // how it is WRITTEN — per language, never derived downstream
|
|
26
|
+
path, // a filesystem path rather than a package/module/crate name
|
|
27
|
+
relative, // reaches inside its own project without leaving it
|
|
28
|
+
},
|
|
29
|
+
resolved: { // null when unresolvable — record it, never guess
|
|
30
|
+
target, // project name, or null when external
|
|
31
|
+
file, // workspace-relative resolved file, or null
|
|
32
|
+
external, // true when it resolves outside every project
|
|
33
|
+
packageName, // npm/crate/module package name when external
|
|
34
|
+
} | null,
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Why this is a superset of a graph edge, and why that is the point
|
|
39
|
+
|
|
40
|
+
An Nx dependency is `{ source, target, sourceFile, type }`: which project
|
|
41
|
+
depends on which, and one file to blame. That is everything `nx affected`
|
|
42
|
+
needs, and it was everything this tool produced while it only fed the graph.
|
|
43
|
+
|
|
44
|
+
It is not enough to enforce a boundary. Measured on this workspace,
|
|
45
|
+
`nx graph --file=` emits **no `sourceFile` and no import specifier** on any edge
|
|
46
|
+
at all: every edge in that output carries `source`, `target` and `type`, and no
|
|
47
|
+
other key. A count belongs in the run rather than in this sentence — the
|
|
48
|
+
denominator moves with every project added, and the fact that survives it is
|
|
49
|
+
that the set of keys does not include provenance. Five of the fifteen violation
|
|
50
|
+
types `@nx/enforce-module-boundaries` reports are decided on the raw specifier
|
|
51
|
+
itself, not on the project pair it resolves to:
|
|
52
|
+
|
|
53
|
+
- a relative or absolute path that crosses a project boundary — the projects
|
|
54
|
+
are correct, the _spelling_ is the violation;
|
|
55
|
+
- an external import matched against `bannedExternalImports` (this workspace
|
|
56
|
+
bans `@tauri-apps/*` out of `layer:view`), which is a glob over the specifier;
|
|
57
|
+
- a deep import into a package's nested path, which the package name alone
|
|
58
|
+
cannot distinguish from an import of its entry point;
|
|
59
|
+
- a self-import that goes out through the project's own public alias and back
|
|
60
|
+
in, which resolves to the project it started from and so vanishes from an
|
|
61
|
+
edge list;
|
|
62
|
+
- a dynamic `import()`, which is the same edge as a static import and a
|
|
63
|
+
different rule.
|
|
64
|
+
|
|
65
|
+
So the record keeps `specifier` verbatim and adds `line`/`column`. Both are
|
|
66
|
+
what an edge threw away, and neither can be recovered from the graph
|
|
67
|
+
afterwards. `line`/`column` are 1-based because that is what an editor
|
|
68
|
+
diagnostic and a `file:line:col` terminal report want; converting once here
|
|
69
|
+
beats every consumer remembering which convention this tool chose.
|
|
70
|
+
|
|
71
|
+
### How the specifier is spelled is a per-language fact, so the analyzer states it
|
|
72
|
+
|
|
73
|
+
`specifier` is the raw text; `spelling` is what that text IS in the language it
|
|
74
|
+
was written in. Two bits, because the rules ask two independent questions:
|
|
75
|
+
|
|
76
|
+
| | `path` | `relative` |
|
|
77
|
+
| --------------------------------- | :----: | :--------: |
|
|
78
|
+
| `./x`, `../x`, `.`, `..` (JS) | yes | yes |
|
|
79
|
+
| `/x` (JS) | yes | no |
|
|
80
|
+
| `crate::x`, `self::x`, `super::x` | no | yes |
|
|
81
|
+
| `rba_desktop_lib::run` from a bin | no | yes |
|
|
82
|
+
| `.mod`, `..pkg.sub` (Python) | no | yes |
|
|
83
|
+
| `react`, `serde`, `example.com/m` | no | no |
|
|
84
|
+
|
|
85
|
+
`path` says the specifier is a **filesystem path**: resolvable by path
|
|
86
|
+
arithmetic against the importing file, and naming no package. It decides
|
|
87
|
+
whether a specifier may receive a synthesized external node, and which message
|
|
88
|
+
an unresolvable one gets. `relative` says the specifier **reaches inside its
|
|
89
|
+
own project without going out through the project's public name** — the
|
|
90
|
+
counter-evidence `noSelfCircularDependencies` looks for.
|
|
91
|
+
|
|
92
|
+
**Why the analyzer answers and not the rules.** The rules layer used to derive
|
|
93
|
+
both from the text with one predicate — `.`, `..`, `./`, `../` — which is
|
|
94
|
+
JavaScript's shape and only JavaScript's. Measured on this workspace, that
|
|
95
|
+
reported two violations that were not: `use super::product_name` and a binary
|
|
96
|
+
calling its own package's library crate, both ordinary Rust. Python is the same
|
|
97
|
+
exposure with the sign reversed on each bit: `..pkg` is relative and read as a
|
|
98
|
+
package name, while a bare `.` is not a path and would have been reported as
|
|
99
|
+
one. The analyzer already knows the language and has already resolved the
|
|
100
|
+
import; the rules layer knows neither, and a language table there would be a
|
|
101
|
+
second registry drifting from `LANGUAGE_BY_EXTENSION`. So the record carries the
|
|
102
|
+
fact and the rule reads it.
|
|
103
|
+
|
|
104
|
+
The field is **mandatory**, and `evaluate()` throws on a record that omits it
|
|
105
|
+
rather than falling back to the JavaScript shape. A default is how the next
|
|
106
|
+
analyzer inherits this bug silently; a throw is how it is told, once, at the
|
|
107
|
+
first record it produces.
|
|
108
|
+
|
|
109
|
+
### Intra-project imports are emitted too
|
|
110
|
+
|
|
111
|
+
A relative import that stays inside one project produces no graph edge — there
|
|
112
|
+
is no second project for the edge to reach. It is still a record.
|
|
113
|
+
|
|
114
|
+
Two rules need it. `allowCircularSelfDependency` (off in this workspace, see
|
|
115
|
+
`module-boundaries.config.mjs`) is decided entirely on imports whose source and
|
|
116
|
+
target are the same project: the file reaching its own project through the
|
|
117
|
+
public alias instead of a relative path. And a nested-path ban has to see the
|
|
118
|
+
in-project import to know a file bypassed its own barrel.
|
|
119
|
+
|
|
120
|
+
Dropping these at the analyzer would make those rules unimplementable and the
|
|
121
|
+
loss would be silent — nothing downstream can tell "no violation" from
|
|
122
|
+
"never looked".
|
|
123
|
+
|
|
124
|
+
### `resolved: null` means unresolvable, and that is a finding
|
|
125
|
+
|
|
126
|
+
Resolution is best-effort; pretending is not allowed. When an analyzer cannot
|
|
127
|
+
say where a specifier points, `resolved` is `null` and the reason lands in
|
|
128
|
+
`failures` (below). It never guesses a target from a name that looks similar,
|
|
129
|
+
and it never drops the record.
|
|
130
|
+
|
|
131
|
+
There are two ways to reach `resolved: null`, and the failure's shape says
|
|
132
|
+
which one it was:
|
|
133
|
+
|
|
134
|
+
- A **LITERAL specifier that names a DECLARED project the resolver could not
|
|
135
|
+
answer** — `import { x } from "@acme/ui"` in a native workspace whose
|
|
136
|
+
`archkeep.json` declares a project literally named `@acme/ui` — is a hole:
|
|
137
|
+
the edge that workspace-internal dependency would have carried is missing,
|
|
138
|
+
so the file could not be fully judged. It is a whole-file failure
|
|
139
|
+
(`fileFailure`, `line`/`column` `null`), which makes `check` count the file
|
|
140
|
+
toward `unchecked` and refuse a verdict (exit 3) — the same "could not look"
|
|
141
|
+
shape an unreadable file produces (`cli.mjs` counts `unchecked` by
|
|
142
|
+
`failure.line === null`).
|
|
143
|
+
- A **literal package import that names no declared project** — an uninstalled
|
|
144
|
+
third-party package, a dependency of some other workspace — is a normal,
|
|
145
|
+
permanent state: a workspace with packages is not a missing workspace edge.
|
|
146
|
+
It stays a POSITIONED failure (`line`/`column` set), the "blind spot" that
|
|
147
|
+
does not fail the run.
|
|
148
|
+
- A **dynamic import with a non-literal argument** — `import(somePath)`, or an
|
|
149
|
+
`import()` whose argument is a template literal interpolating a variable — is
|
|
150
|
+
the recurring permanent case. The site is real, the target is not knowable
|
|
151
|
+
statically, and the honest answer is one record with `kind: "dynamic"`, the
|
|
152
|
+
source text of the argument as `specifier`, and a POSITIONED failure
|
|
153
|
+
(`line`/`column` set). The rest of the file's imports were still judged; a
|
|
154
|
+
reader can see this one site in the report's blind-spot section and the run
|
|
155
|
+
does not fail on it.
|
|
156
|
+
|
|
157
|
+
Silently dropping any of them is how a boundary gets bypassed.
|
|
158
|
+
|
|
159
|
+
`external: true` marks a specifier that resolves outside every project — an npm
|
|
160
|
+
package, a crate, a Go module from the proxy, a stdlib module. `target` is then
|
|
161
|
+
`null` and `packageName` carries the package's own name, which is what a
|
|
162
|
+
`bannedExternalImports` glob is matched against. For a scoped npm package that
|
|
163
|
+
is `@scope/name`, not the deep path: `@tauri-apps/api/window` has
|
|
164
|
+
`packageName: "@tauri-apps/api"` and keeps the deep path in `specifier`, so a
|
|
165
|
+
rule can match either without re-parsing.
|
|
166
|
+
|
|
167
|
+
### An analyzer never throws on a malformed file
|
|
168
|
+
|
|
169
|
+
A parse failure is data, not an exception. The analyzer returns what it did
|
|
170
|
+
parse and appends a `failure` naming the file and, where it knows one, the
|
|
171
|
+
position. One unparseable file must not blank a whole run: a tool that reports
|
|
172
|
+
zero violations because it crashed on file three, and a tool that reports zero
|
|
173
|
+
violations because there are none, print the same thing.
|
|
174
|
+
|
|
175
|
+
The rule holds for I/O too — a file that cannot be read is a failure record,
|
|
176
|
+
not a throw.
|
|
177
|
+
|
|
178
|
+
The one thing that _does_ throw is a language whose analyzer is not written
|
|
179
|
+
yet. That is not a malformed input, it is a missing implementation, and it
|
|
180
|
+
fails loudly rather than reporting an empty result that reads as "clean".
|
|
181
|
+
|
|
182
|
+
## The envelope
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
{
|
|
186
|
+
imports, // ImportSite[] — every import site, in source order
|
|
187
|
+
failures, // AnalysisFailure[] — what could not be parsed, read, or resolved
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`failures` carries `{ sourceFile, line, column, reason }`; `line`/`column` are
|
|
192
|
+
`null` when the failure is about the file as a whole rather than one position.
|
|
193
|
+
Both arrays are always present and always arrays — a consumer never has to
|
|
194
|
+
check for `undefined` before iterating.
|
|
195
|
+
|
|
196
|
+
## The dispatcher
|
|
197
|
+
|
|
198
|
+
`analyzeFile` picks an analyzer by **file extension** and nothing else. It does
|
|
199
|
+
not sniff content, and it does not consult the project's language tags: a
|
|
200
|
+
`.go` file in a project tagged `type:lib` is Go, and a project's tags describe
|
|
201
|
+
its boundary, not its syntax.
|
|
202
|
+
|
|
203
|
+
**An unknown extension is a no-op, not an error** — it returns the empty
|
|
204
|
+
envelope. The dispatcher is pointed at whatever files a project owns, and a
|
|
205
|
+
project's tracked files include `README.md`, `project.json`, `.svg`, and a
|
|
206
|
+
lockfile. Treating those as errors would make every run red for reasons no
|
|
207
|
+
rule cares about, and the pressure to fix it would be an ignore list that
|
|
208
|
+
someone has to keep in sync with reality. An extension with no analyzer simply
|
|
209
|
+
has no imports this tool can see, which is the truth.
|
|
210
|
+
|
|
211
|
+
The registry is `LANGUAGE_BY_EXTENSION` in `analyze.mjs`, and it is the one
|
|
212
|
+
place an extension is mapped. A language whose analyzer arrives adds itself
|
|
213
|
+
there and nowhere else.
|
|
214
|
+
|
|
215
|
+
## What an analyzer is handed
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
analyze({ sourceFile, text, workspace }) -> AnalysisResult
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`text` is passed in already read, so the caller decides the read strategy and
|
|
222
|
+
a test can drive an analyzer from an in-memory string — the same injectable
|
|
223
|
+
shape the graph resolvers next door already use.
|
|
224
|
+
|
|
225
|
+
**Byte tolerance.** `text` arrives exactly as the file decodes — a UTF-8 BOM
|
|
226
|
+
and CRLF line endings included — and no layer normalises it on read: a BOM
|
|
227
|
+
strip shifts every column after it by one and a CRLF collapse shifts every
|
|
228
|
+
line, so a diagnostic traded for a clean parse would point one position off.
|
|
229
|
+
Each parser tolerates those spellings itself instead. A source parser blanks
|
|
230
|
+
a tolerated byte for one of its own length (never deleting), so every offset
|
|
231
|
+
its records carry stays an offset into the bytes on disk and `positionAt`
|
|
232
|
+
reports what a reader counting the file would count. A manifest reader
|
|
233
|
+
(`parseGoModulePath`, `parseManifest`) emits no position at all, so there is
|
|
234
|
+
nothing to shift and it removes the byte outright.
|
|
235
|
+
|
|
236
|
+
`workspace` is `{ root, projects, filesOf, readFile }`: the absolute workspace
|
|
237
|
+
root, every project as `{ name, root }` with a workspace-relative root, the
|
|
238
|
+
tracked-file list per project name, and a workspace-relative reader returning
|
|
239
|
+
`null` for a missing file. That is a superset of the `(projects, filesOf,
|
|
240
|
+
readFile)` triple the graph resolvers take, plus the absolute `root` that
|
|
241
|
+
TypeScript's resolver needs.
|
|
242
|
+
|
|
243
|
+
### Resolution is delegated, never reimplemented
|
|
244
|
+
|
|
245
|
+
TypeScript resolution is `ts.resolveModuleName` — a public TypeScript API and
|
|
246
|
+
already a declared dependency. `./typescript.test.mjs` is where it is held to
|
|
247
|
+
the three answers this analyzer needs: a `tsconfig.base.json` path alias
|
|
248
|
+
resolves to the file it names, a secondary entry resolves to its own file rather
|
|
249
|
+
than to the package's main one, and a real third-party package comes back
|
|
250
|
+
flagged `isExternalLibraryImport`. Reimplementing `tsconfig.base.json` path mapping,
|
|
251
|
+
`exports` conditions, and extension probing would be a second answer to a
|
|
252
|
+
question TypeScript already answers, and the two would disagree exactly where
|
|
253
|
+
it matters.
|
|
254
|
+
|
|
255
|
+
Where a language has no comparable API — Go's import paths, Cargo's path
|
|
256
|
+
dependencies, uv's sources — resolution stays static and manifest-driven, for
|
|
257
|
+
the reason that governs this whole directory: the graph has to compute on a
|
|
258
|
+
machine with none of those toolchains installed. Each analyzer states its own
|
|
259
|
+
parse limits in its header, the way `go.mjs` does.
|