@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,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.