arkgate 4.8.23 → 4.8.25
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/CHANGELOG.md +153 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +41 -91
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +707 -121
- package/bin/ark-mcp-runtime.mjs +448 -169
- package/bin/ark-mcp.mjs +68 -4
- package/bin/ark-shared.mjs +42 -22
- package/bin/ark.mjs +65 -97
- package/bin/lib/adapter-contract-types.mjs +5 -0
- package/bin/lib/adapter-contract.mjs +15 -0
- package/bin/lib/adoption-stance.mjs +2 -2
- package/bin/lib/adr-path.mjs +3 -3
- package/bin/lib/agent-gates.mjs +0 -61
- package/bin/lib/agent-homes.mjs +12 -21
- package/bin/lib/agent-projection-command.mjs +9 -5
- package/bin/lib/ambient-state.mjs +9 -30
- package/bin/lib/analysis-engine.mjs +15 -11
- package/bin/lib/architecture-scan.mjs +61 -15
- package/bin/lib/ark-order-doctor.mjs +4 -29
- package/bin/lib/ark-order-facts.mjs +319 -17
- package/bin/lib/ark-order-sensors.mjs +28 -101
- package/bin/lib/ark-order-types.mjs +3 -2
- package/bin/lib/ark-run-doctor.mjs +17 -28
- package/bin/lib/ark-run-facts.mjs +461 -48
- package/bin/lib/ark-run-sensors.mjs +6 -4
- package/bin/lib/arkgate-pins.mjs +91 -0
- package/bin/lib/arkrule-file-hints.mjs +2 -47
- package/bin/lib/arkrules-contract.mjs +124 -133
- package/bin/lib/arkrules-sensors.mjs +55 -252
- package/bin/lib/check-args.mjs +85 -1
- package/bin/lib/ci-and-commands.mjs +24 -78
- package/bin/lib/ci-merge-boundary.mjs +1 -1
- package/bin/lib/class-source-scan.mjs +479 -0
- package/bin/lib/clone-detection.mjs +474 -0
- package/bin/lib/codex-home.mjs +5 -3
- package/bin/lib/config-contract-slices.mjs +739 -0
- package/bin/lib/config-contract.mjs +42 -607
- package/bin/lib/config-extras.mjs +16 -22
- package/bin/lib/config-warnings.mjs +32 -0
- package/bin/lib/contract-smells.mjs +33 -15
- package/bin/lib/core-ratchet.mjs +0 -2
- package/bin/lib/dashboard-cli.mjs +62 -0
- package/bin/lib/deep-module-coach.mjs +2 -4
- package/bin/lib/deploy-path.mjs +13 -38
- package/bin/lib/design-delta.mjs +1 -1
- package/bin/lib/design-smells.mjs +2 -2
- package/bin/lib/diagnostic-catalog.mjs +23 -9
- package/bin/lib/doctor-advisories.mjs +78 -8
- package/bin/lib/doctor-green-cite.mjs +1 -1
- package/bin/lib/doctor-human.mjs +1 -1
- package/bin/lib/doctor-next-actions.mjs +19 -4
- package/bin/lib/doctor-plan.mjs +9 -13
- package/bin/lib/duplication-io.mjs +572 -0
- package/bin/lib/effective-contract-load.mjs +282 -83
- package/bin/lib/enforcement-profiles.mjs +40 -4
- package/bin/lib/entry-points-io.mjs +424 -0
- package/bin/lib/extra-merge-teeth.mjs +36 -0
- package/bin/lib/field-install.mjs +38 -14
- package/bin/lib/first-run-help.mjs +10 -5
- package/bin/lib/flat-parent-importers.mjs +139 -0
- package/bin/lib/flat-parent-pilot.mjs +40 -100
- package/bin/lib/gate-files.mjs +58 -90
- package/bin/lib/git-change-scope.mjs +109 -0
- package/bin/lib/github-enforcement.mjs +166 -29
- package/bin/lib/golden-pattern.mjs +1 -1
- package/bin/lib/hook-templates.mjs +17 -15
- package/bin/lib/host-support-matrix.mjs +3 -1
- package/bin/lib/html-report-advisories.mjs +4 -0
- package/bin/lib/html-report-depth.mjs +2 -2
- package/bin/lib/html-report.mjs +2 -2
- package/bin/lib/import-graph-projection.mjs +112 -0
- package/bin/lib/import-resolve.mjs +13 -1
- package/bin/lib/improvement-compass-doctor.mjs +1 -24
- package/bin/lib/install-migrate.mjs +50 -73
- package/bin/lib/invariant-coverage-io.mjs +4 -3
- package/bin/lib/invariant-coverage.mjs +85 -11
- package/bin/lib/invariant-probe-cli.mjs +347 -0
- package/bin/lib/invariant-probe-io.mjs +165 -0
- package/bin/lib/invariant-probe-runner.mjs +268 -0
- package/bin/lib/invariant-probe-sites.mjs +174 -0
- package/bin/lib/invariant-probe-workspace.mjs +183 -0
- package/bin/lib/invariant-probe.mjs +461 -0
- package/bin/lib/invariant-tests-path.mjs +16 -22
- package/bin/lib/literal-path-drift.mjs +4 -2
- package/bin/lib/managed-upgrade.mjs +2 -8
- package/bin/lib/mcp-adoption.mjs +13 -6
- package/bin/lib/mcp-hook-payload.mjs +403 -63
- package/bin/lib/orphan-modules-io.mjs +458 -0
- package/bin/lib/orphan-modules.mjs +251 -0
- package/bin/lib/outside-importers.mjs +197 -0
- package/bin/lib/package-manager.mjs +60 -2
- package/bin/lib/parse-health.mjs +1 -1
- package/bin/lib/passthrough-commands.mjs +83 -0
- package/bin/lib/persistence-write-hint.mjs +106 -0
- package/bin/lib/pin-dependent-gates.mjs +117 -0
- package/bin/lib/policy-delta-io.mjs +219 -14
- package/bin/lib/post-green-path.mjs +1 -1
- package/bin/lib/prepare-change.mjs +115 -7
- package/bin/lib/prepare-write.mjs +2 -2
- package/bin/lib/presets.mjs +59 -73
- package/bin/lib/product-copy.mjs +1 -6
- package/bin/lib/project-root.mjs +31 -62
- package/bin/lib/remediation.mjs +23 -4
- package/bin/lib/resolved-candidate-facts.mjs +405 -117
- package/bin/lib/rules-inventory.mjs +22 -3
- package/bin/lib/rules-under-contract.mjs +247 -9
- package/bin/lib/scan-files.mjs +36 -4
- package/bin/lib/schema-validation.mjs +130 -0
- package/bin/lib/sensor-promote-cli.mjs +2 -11
- package/bin/lib/sensor-promote-io.mjs +33 -22
- package/bin/lib/sensor-promotion.mjs +1 -1
- package/bin/lib/skill-catalog-apply.mjs +1 -1
- package/bin/lib/skill-install.mjs +42 -38
- package/bin/lib/snippet-analysis.mjs +140 -6
- package/bin/lib/source-policy.mjs +34 -0
- package/bin/lib/start-install-recovery.mjs +72 -2
- package/bin/lib/start-preview.mjs +31 -1
- package/bin/lib/states-transitions-presence.mjs +3 -3
- package/bin/lib/status-command.mjs +35 -22
- package/bin/lib/status-manifest.mjs +50 -30
- package/bin/lib/status-transition-catalog.mjs +0 -6
- package/bin/lib/suggestions.mjs +3 -3
- package/bin/lib/team-parliament-io.mjs +76 -88
- package/bin/lib/team-parliament.mjs +21 -10
- package/bin/lib/upgrade-behavior-changes.mjs +2 -2
- package/bin/lib/upgrade-command.mjs +2 -24
- package/bin/lib/upgrade-outcome.mjs +1 -1
- package/bin/lib/upstream-report.mjs +21 -10
- package/bin/lib/violations.mjs +10 -9
- package/bin/lib/weakest-link.mjs +13 -0
- package/bin/lib/write-path-capabilities.mjs +6 -4
- package/dist/chunk-A5EIY3HI.cjs +5 -0
- package/dist/chunk-AHACEKSV.js +16 -0
- package/dist/chunk-SVSQVGFJ.js +5 -0
- package/dist/chunk-UN5EH4QV.cjs +16 -0
- package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
- package/dist/configTypes-GPvvdleM.d.ts +257 -0
- package/dist/eslint/index.cjs +18 -5
- package/dist/eslint/index.d.cts +368 -0
- package/dist/eslint/index.d.ts +139 -156
- package/dist/eslint/index.js +11 -5
- package/dist/index.cjs +36 -35
- package/dist/index.d.cts +5547 -0
- package/dist/index.d.ts +3283 -198
- package/dist/index.js +36 -35
- package/dist/inspectorListen-6DQVJ4DC.js +1 -0
- package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
- package/dist/nestjs/index.cjs +1 -18
- package/dist/nestjs/index.d.cts +22 -0
- package/dist/nestjs/index.d.ts +1 -3
- package/dist/nestjs/index.js +1 -18
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.cts +155 -0
- package/dist/order/index.d.ts +15 -3
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +13 -30
- package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
- package/dist/runtime/index.d.ts +2741 -133
- package/dist/runtime/index.js +13 -30
- package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
- package/dist/types-CnauMQQL.d.ts +1800 -0
- package/docs/README.md +3 -1
- package/docs/agent-guide.md +217 -16
- package/docs/ai-gates.md +174 -26
- package/docs/arkorder.md +32 -15
- package/docs/brownfield-adoption.md +5 -1
- package/docs/configuration.md +230 -31
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +212 -19
- package/docs/package-surface.md +35 -9
- package/docs/product-voice.md +7 -1
- package/package.json +69 -19
- package/schemas/ark.analysis-result.schema.json +15 -0
- package/schemas/ark.config.schema.json +77 -12
- package/schemas/ark.invariant-probe.schema.json +368 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/schemas/ark.status-manifest.schema.json +22 -0
- package/server.json +3 -9
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
- package/templates/agent-skills/ark-contract/SKILL.md +17 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
- package/templates/agent-skills/ark-explore/SKILL.md +7 -0
- package/templates/agent-skills/ark-fix/SKILL.md +20 -0
- package/templates/agent-skills/ark-order/SKILL.md +4 -2
- package/templates/agent-skills/ark-place/SKILL.md +8 -0
- package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
- package/templates/arkrules/PersistenceAdapters.json +1 -1
- package/templates/arkrules/PresentationAdapters.json +1 -1
- package/templates/skills/ark-adopt.md +5 -1
- package/templates/skills/ark-contract.md +17 -0
- package/templates/skills/ark-coverage.md +2 -0
- package/templates/skills/ark-explore.md +7 -0
- package/templates/skills/ark-fix.md +20 -0
- package/templates/skills/ark-order.md +4 -2
- package/templates/skills/ark-place.md +8 -0
- package/templates/skills/ark-runtime.md +6 -1
- package/bin/lib/ark-order-error.mjs +0 -18
- package/bin/lib/ark-order-invariants.mjs +0 -346
- package/dist/types-Ik_OakcI.d.ts +0 -183
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files nothing imports — Tooling (ADR 0037).
|
|
3
|
+
*
|
|
4
|
+
* Reads the importer index the scan projected from resolved facts, the entry
|
|
5
|
+
* evidence, and the importers that live outside the governed graph: test
|
|
6
|
+
* files, non-governed project source, path literals that name a file, and
|
|
7
|
+
* lexical dynamic reach (`import(\`./x/${…}\`)`, `import.meta.glob`,
|
|
8
|
+
* `new URL(…, import.meta.url)`, `require.context`). The pure decision lives
|
|
9
|
+
* in `orphan-modules.mjs` (generated from src/domain/orphanModules.ts).
|
|
10
|
+
*
|
|
11
|
+
* Bounded: the outside pass reads at most 5000 files of 256 KB each and runs
|
|
12
|
+
* only while a file without an importer remains. Advisory only — never a gate
|
|
13
|
+
* input, never in the write hook, MCP write tools, ESLint or --strict-merge.
|
|
14
|
+
*/
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { globToRegExp, layerForRelativePath } from '../ark-layer-match.mjs';
|
|
17
|
+
import { ambientEntries, collectEntryPoints, entriesBySource } from './entry-points-io.mjs';
|
|
18
|
+
import { graphScanLimit } from './graph-blind.mjs';
|
|
19
|
+
import {
|
|
20
|
+
ORPHAN_MODULE_NEXT,
|
|
21
|
+
deferredUnusedExports,
|
|
22
|
+
findOrphanModules,
|
|
23
|
+
findUnusedExports,
|
|
24
|
+
moduleStem,
|
|
25
|
+
orphanModuleLine,
|
|
26
|
+
unavailableOrphanModules,
|
|
27
|
+
unusedExportLine,
|
|
28
|
+
} from './orphan-modules.mjs';
|
|
29
|
+
import {
|
|
30
|
+
MAX_EXTERNAL_IMPORTER_FILES,
|
|
31
|
+
createResolver,
|
|
32
|
+
dirOf,
|
|
33
|
+
fileImports,
|
|
34
|
+
readSource,
|
|
35
|
+
stripExt,
|
|
36
|
+
testPathMatcher,
|
|
37
|
+
walkExternalSources,
|
|
38
|
+
} from './outside-importers.mjs';
|
|
39
|
+
|
|
40
|
+
const MAX_EXPORT_PARSE_FILES = 3000;
|
|
41
|
+
const PRINT_CAP = 10;
|
|
42
|
+
const QUOTED = /(['"`])([^'"`\n]{1,300})\1/g;
|
|
43
|
+
const TEMPLATE_HEAD = /\b(?:import|require)\s*\(\s*(?:`([^`$]*)\$\{|(['"])([^'"\n]+)\2\s*\+)/g;
|
|
44
|
+
const META_GLOB = /import\.meta\.glob(?:Eager)?\s*(?:<[^>]*>)?\s*\(\s*(\[[^\]]*\]|(['"`])[^'"`\n]+\2)/g;
|
|
45
|
+
const NEW_URL = /new\s+URL\s*\(\s*(['"`])([^'"`\n]+)\1\s*,\s*import\.meta\.url/g;
|
|
46
|
+
const REQUIRE_CONTEXT = /require\.context\s*\(\s*(['"`])([^'"`\n]+)\1/g;
|
|
47
|
+
const REACH_GATE = /import\s*\(|require\s*\(|import\.meta\.glob|new\s+URL|require\.context/;
|
|
48
|
+
const GENERATED_MARK = /GENERATED FILE|@generated|DO NOT EDIT|\bgenerated (?:from|by)\b/i;
|
|
49
|
+
const GENERATED_HEADER_CHARS = 1200;
|
|
50
|
+
const PATH_IN_HEADER = /[\w@.-][\w@./-]*\.[cm]?[jt]sx?(?![\w/])/g;
|
|
51
|
+
|
|
52
|
+
/** `from`'s directory joined with a relative literal; null for bare / escaping. */
|
|
53
|
+
function relativeTo(fromRel, literal) {
|
|
54
|
+
if (typeof literal !== 'string') return null;
|
|
55
|
+
const raw = literal.startsWith('/') ? literal.slice(1) : literal;
|
|
56
|
+
const base = literal.startsWith('/') ? '' : dirOf(fromRel);
|
|
57
|
+
if (!literal.startsWith('.') && !literal.startsWith('/')) return null;
|
|
58
|
+
const joined = path.posix.normalize(base ? `${base}/${raw}` : raw);
|
|
59
|
+
if (joined === '..' || joined.startsWith('../')) return null;
|
|
60
|
+
return joined.replace(/^\.\//, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A generated copy names the file it was generated from in its header. */
|
|
64
|
+
function generatedSources(rel, text, governed) {
|
|
65
|
+
const header = text.slice(0, GENERATED_HEADER_CHARS);
|
|
66
|
+
if (!GENERATED_MARK.test(header)) return null;
|
|
67
|
+
const dir = dirOf(rel);
|
|
68
|
+
const sources = [];
|
|
69
|
+
for (const token of header.match(PATH_IN_HEADER) ?? []) {
|
|
70
|
+
const hit = governed.has(token) ? token : dir && governed.has(`${dir}/${token}`) ? `${dir}/${token}` : null;
|
|
71
|
+
if (hit) sources.push(hit);
|
|
72
|
+
}
|
|
73
|
+
return sources;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Heuristic: a match on a line that is (or has turned into) a comment is prose, not code. */
|
|
77
|
+
function inComment(text, index) {
|
|
78
|
+
const prefix = text.slice(text.lastIndexOf('\n', index - 1) + 1, index);
|
|
79
|
+
return /^\s*(?:\*|\/\*|\/\/)/.test(prefix) || /(?:^|[^:'"`])\/\//.test(prefix);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Exported names, or null when not knowable (`export *`, `export =`, destructuring). */
|
|
83
|
+
function exportedNames(ts, sourceFile) {
|
|
84
|
+
const names = [];
|
|
85
|
+
for (const statement of sourceFile.statements) {
|
|
86
|
+
if (ts.isExportDeclaration(statement)) {
|
|
87
|
+
const clause = statement.exportClause;
|
|
88
|
+
if (!clause) return null;
|
|
89
|
+
if (ts.isNamespaceExport?.(clause)) names.push(clause.name.text);
|
|
90
|
+
else for (const element of clause.elements) names.push(element.name.text);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ts.isExportAssignment(statement)) {
|
|
94
|
+
if (statement.isExportEquals) return null;
|
|
95
|
+
names.push('default');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const modifiers = statement.modifiers ?? [];
|
|
99
|
+
if (!modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
100
|
+
if (modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)) {
|
|
101
|
+
names.push('default');
|
|
102
|
+
} else if (ts.isVariableStatement(statement)) {
|
|
103
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
104
|
+
if (!ts.isIdentifier(declaration.name)) return null;
|
|
105
|
+
names.push(declaration.name.text);
|
|
106
|
+
}
|
|
107
|
+
} else if (statement.name && ts.isIdentifier(statement.name)) {
|
|
108
|
+
names.push(statement.name.text);
|
|
109
|
+
} else {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return names;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Where a file may be loaded without a static import. Returns one sentence per
|
|
118
|
+
* reached candidate, from lexical evidence in `rel`'s text.
|
|
119
|
+
*/
|
|
120
|
+
function reachFrom(rel, text, candidates, byBase) {
|
|
121
|
+
const hits = new Map();
|
|
122
|
+
const mark = (file, why) => {
|
|
123
|
+
if (!hits.has(file)) hits.set(file, why);
|
|
124
|
+
};
|
|
125
|
+
for (const match of text.matchAll(QUOTED)) {
|
|
126
|
+
if (inComment(text, match.index)) continue;
|
|
127
|
+
const literal = match[2];
|
|
128
|
+
const base = literal.slice(literal.lastIndexOf('/') + 1);
|
|
129
|
+
for (const file of byBase.get(base) ?? []) {
|
|
130
|
+
const exact = literal === file || literal.endsWith(`/${file}`) || relativeTo(rel, literal) === file;
|
|
131
|
+
const bare = literal === base && !base.startsWith('index.');
|
|
132
|
+
if (exact || bare) mark(file, `${rel} names it by path`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!REACH_GATE.test(text)) return hits;
|
|
136
|
+
for (const match of text.matchAll(TEMPLATE_HEAD)) {
|
|
137
|
+
if (inComment(text, match.index)) continue;
|
|
138
|
+
const prefix = relativeTo(rel, match[1] ?? match[3]);
|
|
139
|
+
if (!prefix) continue;
|
|
140
|
+
for (const file of candidates) {
|
|
141
|
+
if (file.startsWith(prefix)) mark(file, `a dynamic import in ${rel} can load files under ${prefix}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const match of text.matchAll(META_GLOB)) {
|
|
145
|
+
if (inComment(text, match.index)) continue;
|
|
146
|
+
const literals = [...match[1].matchAll(/(['"`])([^'"`\n]+)\1/g)].map((item) => item[2]);
|
|
147
|
+
for (const literal of literals) {
|
|
148
|
+
if (literal.startsWith('!')) continue;
|
|
149
|
+
const pattern = relativeTo(rel, literal);
|
|
150
|
+
if (!pattern) continue;
|
|
151
|
+
const re = globToRegExp(pattern);
|
|
152
|
+
for (const file of candidates) if (re.test(file)) mark(file, `import.meta.glob in ${rel} matches it`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const match of text.matchAll(NEW_URL)) {
|
|
156
|
+
if (inComment(text, match.index)) continue;
|
|
157
|
+
const target = relativeTo(rel, match[2]);
|
|
158
|
+
if (!target) continue;
|
|
159
|
+
for (const file of candidates) {
|
|
160
|
+
if (file === target || stripExt(file) === stripExt(target)) mark(file, `new URL(…, import.meta.url) in ${rel} points at it`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const match of text.matchAll(REQUIRE_CONTEXT)) {
|
|
164
|
+
if (inComment(text, match.index)) continue;
|
|
165
|
+
const prefix = relativeTo(rel, match[2]);
|
|
166
|
+
if (!prefix) continue;
|
|
167
|
+
for (const file of candidates) {
|
|
168
|
+
if (file.startsWith(`${prefix.replace(/\/$/, '')}/`)) mark(file, `require.context in ${rel} can load it`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return hits;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function addUse(namedUse, file, names) {
|
|
175
|
+
const previous = namedUse.get(file);
|
|
176
|
+
if (previous === '*') return;
|
|
177
|
+
if (names === '*') {
|
|
178
|
+
namedUse.set(file, '*');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const set = previous ?? new Set();
|
|
182
|
+
for (const name of names) set.add(name);
|
|
183
|
+
namedUse.set(file, set);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Importers outside the governed graph plus lexical reach.
|
|
188
|
+
* Compact runs resolve only specifiers whose stem matches a candidate.
|
|
189
|
+
*/
|
|
190
|
+
function scanOutside({ root, ts, config, governed, candidates, details, governedScan }) {
|
|
191
|
+
const isTest = testPathMatcher(config);
|
|
192
|
+
const candidateSet = new Set(candidates);
|
|
193
|
+
const stems = new Set();
|
|
194
|
+
const byBase = new Map();
|
|
195
|
+
for (const file of candidates) {
|
|
196
|
+
stems.add(moduleStem(file));
|
|
197
|
+
const segments = file.split('/');
|
|
198
|
+
if (moduleStem(file) === 'index' && segments.length > 1) stems.add(segments[segments.length - 2]);
|
|
199
|
+
const base = segments[segments.length - 1];
|
|
200
|
+
byBase.set(base, [...(byBase.get(base) ?? []), file]);
|
|
201
|
+
}
|
|
202
|
+
const resolve = createResolver(ts, root, governed);
|
|
203
|
+
const testImporters = new Map();
|
|
204
|
+
const outsideImporters = new Map();
|
|
205
|
+
const dynamicReach = new Map();
|
|
206
|
+
const namedUse = new Map();
|
|
207
|
+
let oversize = 0;
|
|
208
|
+
const bump = (map, file) => map.set(file, (map.get(file) ?? 0) + 1);
|
|
209
|
+
const { files, capped } = walkExternalSources(root, governed);
|
|
210
|
+
for (const rel of files) {
|
|
211
|
+
const read = readSource(path.join(root, rel));
|
|
212
|
+
if (read.oversize) oversize += 1;
|
|
213
|
+
if (read.text == null) continue;
|
|
214
|
+
const test = isTest(rel);
|
|
215
|
+
for (const { specifier, names } of fileImports(ts, rel, read.text, details)) {
|
|
216
|
+
const stem = moduleStem(specifier);
|
|
217
|
+
const last = specifier.split('/').pop() ?? '';
|
|
218
|
+
if (!details && !stems.has(stem) && !stems.has(last)) continue;
|
|
219
|
+
const target = resolve(specifier, rel);
|
|
220
|
+
if (!target) continue;
|
|
221
|
+
if (candidateSet.has(target)) bump(test ? testImporters : outsideImporters, target);
|
|
222
|
+
if (details) addUse(namedUse, target, names);
|
|
223
|
+
}
|
|
224
|
+
for (const [file, why] of reachFrom(rel, read.text, candidates, byBase)) {
|
|
225
|
+
if (test) bump(testImporters, file);
|
|
226
|
+
else if (!dynamicReach.has(file)) dynamicReach.set(file, why);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Governed files: lexical reach, plus generated copies (the header names the source).
|
|
230
|
+
const generated = new Set();
|
|
231
|
+
const generatedFrom = new Map();
|
|
232
|
+
for (const rel of governedScan ?? []) {
|
|
233
|
+
const read = readSource(path.join(root, rel));
|
|
234
|
+
if (read.text == null) continue;
|
|
235
|
+
const sources = generatedSources(rel, read.text, governed);
|
|
236
|
+
if (sources) {
|
|
237
|
+
generated.add(rel);
|
|
238
|
+
for (const source of sources) if (source !== rel && !generatedFrom.has(source)) generatedFrom.set(source, rel);
|
|
239
|
+
}
|
|
240
|
+
if (candidates.length === 0) continue;
|
|
241
|
+
for (const [file, why] of reachFrom(rel, read.text, candidates, byBase)) {
|
|
242
|
+
if (file !== rel && !dynamicReach.has(file)) dynamicReach.set(file, why);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const source of generatedFrom.keys()) if (candidateSet.has(source)) bump(outsideImporters, source);
|
|
246
|
+
return { testImporters, outsideImporters, dynamicReach, namedUse, generated, generatedFrom, capped, oversize };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function partialReasonsFor(importGraph, outside, reachDeferred, unmapped) {
|
|
250
|
+
const reasons = [];
|
|
251
|
+
if (importGraph.completeness !== 'complete') {
|
|
252
|
+
reasons.push('The import analysis was incomplete, so some importers may be missing.');
|
|
253
|
+
}
|
|
254
|
+
if (importGraph.invalidFiles > 0) {
|
|
255
|
+
reasons.push(`${importGraph.invalidFiles} governed file(s) did not parse, so their imports are missing.`);
|
|
256
|
+
}
|
|
257
|
+
const followed = [];
|
|
258
|
+
if (importGraph.dynamicSites > 0) followed.push(`${importGraph.dynamicSites} dynamic`);
|
|
259
|
+
if (importGraph.unresolvedTotal > 0) followed.push(`${importGraph.unresolvedTotal} unresolved`);
|
|
260
|
+
if (followed.length > 0) {
|
|
261
|
+
reasons.push(`Some imports could not be followed (${followed.join(', ')}). These files may still be used.`);
|
|
262
|
+
}
|
|
263
|
+
if (unmapped.length > 0) {
|
|
264
|
+
const sample = unmapped.slice(0, 3).map((row) => row.declared).join(', ');
|
|
265
|
+
reasons.push(`${unmapped.length} package.json entry path(s) map to no governed source file (${sample}).`);
|
|
266
|
+
}
|
|
267
|
+
if (outside && outside.capped > 0) {
|
|
268
|
+
reasons.push(`The outside-importer pass stopped at ${MAX_EXTERNAL_IMPORTER_FILES} files; ${outside.capped} were not read.`);
|
|
269
|
+
}
|
|
270
|
+
if (outside && outside.oversize > 0) {
|
|
271
|
+
reasons.push(`${outside.oversize} file(s) over 256 KB were not read for imports.`);
|
|
272
|
+
}
|
|
273
|
+
if (reachDeferred) {
|
|
274
|
+
reasons.push(`Dynamic loading was not scanned at this size (${importGraph.files.length} governed files).`);
|
|
275
|
+
}
|
|
276
|
+
return reasons;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function unusedExportsFor({ root, ts, importGraph, entries, outside, partialReasons, skip }) {
|
|
280
|
+
const namedUse = new Map();
|
|
281
|
+
importGraph.namedUse.forEach((names, index) => addUse(namedUse, importGraph.files[index], names));
|
|
282
|
+
outside.namedUse.forEach((names, file) => addUse(namedUse, file, names));
|
|
283
|
+
// A generated copy's importers use the source it was generated from.
|
|
284
|
+
for (const [source, copy] of outside.generatedFrom) {
|
|
285
|
+
const use = namedUse.get(copy);
|
|
286
|
+
if (use !== undefined) addUse(namedUse, source, use);
|
|
287
|
+
}
|
|
288
|
+
const exportsByFile = new Map();
|
|
289
|
+
let parsed = 0;
|
|
290
|
+
let unparsed = 0;
|
|
291
|
+
for (const [file, use] of [...namedUse].sort(([left], [right]) => (left < right ? -1 : 1))) {
|
|
292
|
+
if (use === '*' || entries.has(file) || skip.has(file) || outside.generated.has(file)) continue;
|
|
293
|
+
if (parsed >= MAX_EXPORT_PARSE_FILES) {
|
|
294
|
+
unparsed += 1;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const read = readSource(path.join(root, file));
|
|
298
|
+
if (read.text == null) {
|
|
299
|
+
exportsByFile.set(file, null);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
parsed += 1;
|
|
303
|
+
exportsByFile.set(file, exportedNames(ts, ts.createSourceFile(file, read.text, ts.ScriptTarget.Latest, false)));
|
|
304
|
+
}
|
|
305
|
+
const reasons = [...partialReasons];
|
|
306
|
+
if (unparsed > 0) reasons.push(`${unparsed} file(s) past the ${MAX_EXPORT_PARSE_FILES}-file parse cap were not checked.`);
|
|
307
|
+
return findUnusedExports({ exportsByFile, namedUse, entries: new Set(entries.keys()), partialReasons: reasons });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {{ root: string, config?: object, ts?: object, importGraph?: ReturnType<import('./import-graph-projection.mjs').projectImporterIndex>, details?: boolean, today?: string }} input
|
|
312
|
+
*/
|
|
313
|
+
export function computeOrphanModules(input) {
|
|
314
|
+
const { root, config, ts, importGraph } = input;
|
|
315
|
+
const details = input.details === true;
|
|
316
|
+
if (!importGraph || !ts || typeof ts.preProcessFile !== 'function') {
|
|
317
|
+
return {
|
|
318
|
+
...unavailableOrphanModules(
|
|
319
|
+
!importGraph
|
|
320
|
+
? 'This run had no import facts. Run arkgate-check --doctor to list files nothing imports.'
|
|
321
|
+
: 'TypeScript was not available, so files nothing imports were not checked.'
|
|
322
|
+
),
|
|
323
|
+
unusedExports: deferredUnusedExports(),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const governed = new Set(importGraph.files);
|
|
327
|
+
const today = input.today ?? new Date().toISOString().slice(0, 10);
|
|
328
|
+
const evidence = collectEntryPoints(root, { governed: importGraph.files, config, ts, today });
|
|
329
|
+
const zero = importGraph.files.filter(
|
|
330
|
+
(file, index) => importGraph.importerCount[index] === 0 && !evidence.entries.has(file)
|
|
331
|
+
);
|
|
332
|
+
for (const file of ambientEntries(root, zero)) evidence.entries.set(file, 'ambient');
|
|
333
|
+
const candidates = zero.filter((file) => !evidence.entries.has(file));
|
|
334
|
+
const reachLimit = graphScanLimit(importGraph.files.length);
|
|
335
|
+
const reachDeferred = candidates.length > 0 && importGraph.files.length > reachLimit;
|
|
336
|
+
const outside =
|
|
337
|
+
candidates.length > 0 || details
|
|
338
|
+
? scanOutside({
|
|
339
|
+
root,
|
|
340
|
+
ts,
|
|
341
|
+
config,
|
|
342
|
+
governed,
|
|
343
|
+
candidates,
|
|
344
|
+
details,
|
|
345
|
+
governedScan: importGraph.files.length > reachLimit ? null : importGraph.files,
|
|
346
|
+
})
|
|
347
|
+
: null;
|
|
348
|
+
const partialReasons = partialReasonsFor(importGraph, outside, reachDeferred, evidence.unmapped);
|
|
349
|
+
const unresolvedStems = new Set(importGraph.unresolved.map((row) => moduleStem(row.specifier)));
|
|
350
|
+
const result = findOrphanModules({
|
|
351
|
+
files: importGraph.files,
|
|
352
|
+
importerCount: importGraph.importerCount,
|
|
353
|
+
entries: evidence.entries,
|
|
354
|
+
testImporters: outside?.testImporters ?? new Map(),
|
|
355
|
+
outsideImporters: outside?.outsideImporters ?? new Map(),
|
|
356
|
+
dynamicReach: outside?.dynamicReach ?? new Map(),
|
|
357
|
+
unresolvedStems,
|
|
358
|
+
annotations: evidence.expiredByPath,
|
|
359
|
+
frameworks: evidence.frameworks,
|
|
360
|
+
entryEvidence: { bySource: entriesBySource(evidence.entries), unmapped: evidence.unmapped.length },
|
|
361
|
+
partialReasons,
|
|
362
|
+
notes: evidence.sidecar.note ? [evidence.sidecar.note] : [],
|
|
363
|
+
});
|
|
364
|
+
for (const item of result.orphans) {
|
|
365
|
+
const layer = layerForRelativePath(item.path, config?.layers);
|
|
366
|
+
if (layer) item.layer = layer;
|
|
367
|
+
}
|
|
368
|
+
result.unusedExports = details
|
|
369
|
+
? unusedExportsFor({
|
|
370
|
+
root,
|
|
371
|
+
ts,
|
|
372
|
+
importGraph,
|
|
373
|
+
entries: evidence.entries,
|
|
374
|
+
outside,
|
|
375
|
+
partialReasons: [
|
|
376
|
+
...partialReasons.filter((reason) => !reason.startsWith('Dynamic loading')),
|
|
377
|
+
...(importGraph.files.length > reachLimit
|
|
378
|
+
? [`Generated copies were not detected at this size (${importGraph.files.length} governed files).`]
|
|
379
|
+
: []),
|
|
380
|
+
],
|
|
381
|
+
// Listed files are tier 1. Files only tests or outside code import stay in tier 2.
|
|
382
|
+
skip: new Set(
|
|
383
|
+
candidates.filter((file) => !outside?.testImporters.has(file) && !outside?.outsideImporters.has(file))
|
|
384
|
+
),
|
|
385
|
+
})
|
|
386
|
+
: deferredUnusedExports();
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Compact view: one dim count line, or silence. */
|
|
391
|
+
export function printOrphanModulesCompactLine(section, io) {
|
|
392
|
+
const listed = Number(section?.totals?.listed) || 0;
|
|
393
|
+
if (section?.notAScore !== true || listed === 0) return;
|
|
394
|
+
console.log('');
|
|
395
|
+
// Tier 2 ran only under --all / --report; then the Details section follows, so no pointer.
|
|
396
|
+
const detailsRan = section?.unusedExports?.status !== undefined && section.unusedExports.status !== 'deferred';
|
|
397
|
+
const hint = detailsRan ? '' : ' Details: arkgate-check --doctor --all';
|
|
398
|
+
io.line(' ', io.color.dim(`${listed} file${listed === 1 ? '' : 's'} nothing imports.${hint}`));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Details view. */
|
|
402
|
+
export function printOrphanModulesSection(section, io) {
|
|
403
|
+
if (!section || section.notAScore !== true) return;
|
|
404
|
+
const listed = Number(section.totals?.listed) || 0;
|
|
405
|
+
const testOnly = Number(section.testOnly?.count) || 0;
|
|
406
|
+
const unused = section.unusedExports;
|
|
407
|
+
const unusedRows = Array.isArray(unused?.files) ? unused.files : [];
|
|
408
|
+
if (section.status === 'complete' && listed === 0 && testOnly === 0 && unusedRows.length === 0) return;
|
|
409
|
+
console.log('');
|
|
410
|
+
console.log(io.color.bold('Files nothing imports (not a score)'));
|
|
411
|
+
if (section.status === 'unavailable') {
|
|
412
|
+
io.line(' ', io.color.dim(section.honesty?.[0] ?? section.headline));
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
io.line(listed > 0 ? io.warn : ' ', section.headline);
|
|
416
|
+
for (const item of section.orphans.slice(0, PRINT_CAP)) io.line(io.warn, orphanModuleLine(item));
|
|
417
|
+
const more = listed - Math.min(listed, PRINT_CAP, section.orphans.length);
|
|
418
|
+
if (more > 0) io.line(' ', io.color.dim(`…(+${more} more in doctor JSON)`));
|
|
419
|
+
if (listed > 0) io.line(' ', `Next: ${ORPHAN_MODULE_NEXT}`);
|
|
420
|
+
if (testOnly > 0) {
|
|
421
|
+
io.line(' ', io.color.dim(`${testOnly} file(s) only tests import (for example ${section.testOnly.sample[0]}). Not listed.`));
|
|
422
|
+
}
|
|
423
|
+
for (const text of section.honesty ?? []) io.line(' ', io.color.dim(text));
|
|
424
|
+
if (unusedRows.length > 0) {
|
|
425
|
+
io.line(' ', 'Exports nothing imports by name:');
|
|
426
|
+
for (const row of unusedRows.slice(0, PRINT_CAP)) io.line(io.warn, unusedExportLine(row));
|
|
427
|
+
const extra = unusedRows.length + (unused.truncated ?? 0) - Math.min(unusedRows.length, PRINT_CAP);
|
|
428
|
+
if (extra > 0) io.line(' ', io.color.dim(`…(+${extra} more in doctor JSON)`));
|
|
429
|
+
}
|
|
430
|
+
io.line(' ', io.color.dim('advisory only — nothing imports these files; the check verdict is unchanged'));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** HTML report section (report parity: `data-advisory="orphanModules"`). */
|
|
434
|
+
export function orphanModulesHtml(section, esc = (value) => String(value)) {
|
|
435
|
+
if (!section || section.notAScore !== true) return '';
|
|
436
|
+
const orphans = Array.isArray(section.orphans) ? section.orphans : [];
|
|
437
|
+
const items = orphans
|
|
438
|
+
.map((item) => `<li><span class="tag warn">${esc(item.certainty)}</span> ${esc(orphanModuleLine(item))}</li>`)
|
|
439
|
+
.join('');
|
|
440
|
+
const more = section.truncated > 0 ? `<p class="muted">…(+${section.truncated} more in doctor JSON)</p>` : '';
|
|
441
|
+
const next = orphans.length > 0 ? `<p>Next: ${esc(ORPHAN_MODULE_NEXT)}</p>` : '';
|
|
442
|
+
const testOnly =
|
|
443
|
+
section.testOnly?.count > 0
|
|
444
|
+
? `<p class="muted">${section.testOnly.count} file(s) only tests import. Not listed.</p>`
|
|
445
|
+
: '';
|
|
446
|
+
const honesty = (section.honesty ?? []).map((text) => `<p class="muted">${esc(text)}</p>`).join('');
|
|
447
|
+
const unused = section.unusedExports;
|
|
448
|
+
const unusedRows = Array.isArray(unused?.files) ? unused.files : [];
|
|
449
|
+
const unusedHtml =
|
|
450
|
+
unusedRows.length > 0
|
|
451
|
+
? `<h3>Exports nothing imports by name</h3><ul>${unusedRows
|
|
452
|
+
.map((row) => `<li><code>${esc(row.path)}</code>: ${esc(row.exports.join(', '))}</li>`)
|
|
453
|
+
.join('')}</ul>`
|
|
454
|
+
: unused?.status === 'deferred'
|
|
455
|
+
? `<p class="muted">Unused exports: run <code>${esc(unused.next ?? '')}</code>.</p>`
|
|
456
|
+
: '';
|
|
457
|
+
return `<section class="section card" data-advisory="orphanModules"><h2>Files nothing imports <span class="muted">(advisory — not a score; the verdict is unchanged)</span></h2><p>${esc(section.headline ?? '')}</p><ul>${items}</ul>${more}${next}${testOnly}${honesty}${unusedHtml}</section>`;
|
|
458
|
+
}
|