@shrkcrft/boundaries 0.1.0-alpha.29 → 0.1.0-alpha.30
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/dist/baseline/compute-baseline.d.ts.map +1 -1
- package/dist/baseline/compute-baseline.js +2 -1
- package/dist/extract/extract-tokens.d.ts +19 -1
- package/dist/extract/extract-tokens.d.ts.map +1 -1
- package/dist/extract/extract-tokens.js +53 -1
- package/dist/extract/import-edges.d.ts +49 -0
- package/dist/extract/import-edges.d.ts.map +1 -0
- package/dist/extract/import-edges.js +157 -0
- package/dist/extract/inspect-source.d.ts +6 -0
- package/dist/extract/inspect-source.d.ts.map +1 -1
- package/dist/extract/inspect-source.js +6 -1
- package/dist/extract/parse-imports.d.ts +58 -0
- package/dist/extract/parse-imports.d.ts.map +1 -0
- package/dist/extract/parse-imports.js +155 -0
- package/dist/generated/check-provenance.d.ts +20 -2
- package/dist/generated/check-provenance.d.ts.map +1 -1
- package/dist/generated/check-provenance.js +24 -5
- package/dist/generated/scan-generated.d.ts +49 -3
- package/dist/generated/scan-generated.d.ts.map +1 -1
- package/dist/generated/scan-generated.js +90 -5
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/util/walk-files.d.ts +21 -2
- package/dist/util/walk-files.d.ts.map +1 -1
- package/dist/util/walk-files.js +85 -4
- package/dist/wiring/evaluate-wiring.d.ts +10 -3
- package/dist/wiring/evaluate-wiring.d.ts.map +1 -1
- package/dist/wiring/evaluate-wiring.js +10 -5
- package/dist/wiring/explain-wiring.d.ts +7 -9
- package/dist/wiring/explain-wiring.d.ts.map +1 -1
- package/dist/wiring/explain-wiring.js +18 -0
- package/dist/wiring/plan-wiring-fix.d.ts +16 -1
- package/dist/wiring/plan-wiring-fix.d.ts.map +1 -1
- package/dist/wiring/plan-wiring-fix.js +175 -2
- package/dist/wiring/registration-graph.d.ts +1 -1
- package/dist/wiring/registration-graph.d.ts.map +1 -1
- package/dist/wiring/registration-graph.js +8 -3
- package/dist/wiring/registry-query.d.ts +1 -1
- package/dist/wiring/registry-query.d.ts.map +1 -1
- package/dist/wiring/registry-query.js +7 -3
- package/dist/wiring/scan-wiring-files.d.ts +1 -1
- package/dist/wiring/scan-wiring-files.d.ts.map +1 -1
- package/dist/wiring/scan-wiring-files.js +5 -1
- package/dist/wiring/sink-imports.d.ts +84 -0
- package/dist/wiring/sink-imports.d.ts.map +1 -0
- package/dist/wiring/sink-imports.js +115 -0
- package/package.json +2 -2
|
@@ -14,6 +14,22 @@ function sortSites(sites) {
|
|
|
14
14
|
* committing a rule. The diff/verdict reuse {@link evaluateWiring} so they match
|
|
15
15
|
* the gate exactly (incl. `groupBy` membership). Never throws.
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* The extractor a rule's registered side resolved from — only when EVERY sink
|
|
19
|
+
* shares it. A union of sinks where one is shared and another is inline has no
|
|
20
|
+
* single answer, and naming just one would be a claim the config does not make.
|
|
21
|
+
*/
|
|
22
|
+
function registeredExtractorRef(rule) {
|
|
23
|
+
const sinks = Array.isArray(rule.registered)
|
|
24
|
+
? rule.registered
|
|
25
|
+
: rule.registered
|
|
26
|
+
? [rule.registered]
|
|
27
|
+
: [];
|
|
28
|
+
if (sinks.length === 0)
|
|
29
|
+
return undefined;
|
|
30
|
+
const first = sinks[0].$use;
|
|
31
|
+
return first !== undefined && sinks.every((s) => s.$use === first) ? first : undefined;
|
|
32
|
+
}
|
|
17
33
|
export function explainWiring(projectRoot, rule, options = {}) {
|
|
18
34
|
const sourceSide = wiringSourceSide(rule);
|
|
19
35
|
// For a chain, the reported "registered" side is the LAST hop (the far end).
|
|
@@ -63,12 +79,14 @@ export function explainWiring(projectRoot, rule, options = {}) {
|
|
|
63
79
|
distinctCount: ruleResult?.declaredCount ?? 0,
|
|
64
80
|
filesScanned: declaredFiles.length,
|
|
65
81
|
...(declaredRes.error ? { error: declaredRes.error } : {}),
|
|
82
|
+
...(rule.declared?.$use ? { viaExtractor: rule.declared.$use } : {}),
|
|
66
83
|
},
|
|
67
84
|
registered: {
|
|
68
85
|
sites: sortSites(registeredSites),
|
|
69
86
|
distinctCount: ruleResult?.registeredCount ?? 0,
|
|
70
87
|
filesScanned: registeredFiles.size,
|
|
71
88
|
...(registeredError ? { error: registeredError } : {}),
|
|
89
|
+
...(registeredExtractorRef(rule) ? { viaExtractor: registeredExtractorRef(rule) } : {}),
|
|
72
90
|
},
|
|
73
91
|
declaredNotRegistered: byDirection('declared-missing'),
|
|
74
92
|
registeredNotDeclared: byDirection('registered-missing'),
|
|
@@ -12,7 +12,14 @@ export type WiringFixRefusal =
|
|
|
12
12
|
/** The array literal could not be located in the sink file. */
|
|
13
13
|
| 'array-not-found'
|
|
14
14
|
/** The token is a quoted string in the sink; the declared side is a symbol (or vice versa). */
|
|
15
|
-
| 'token-shape-mismatch'
|
|
15
|
+
| 'token-shape-mismatch'
|
|
16
|
+
/**
|
|
17
|
+
* The sink IMPORTS its array members, so appending to the array alone would
|
|
18
|
+
* leave an unbound reference — a green gate over a file that does not
|
|
19
|
+
* compile. Raised whenever the needed import cannot be derived with
|
|
20
|
+
* certainty.
|
|
21
|
+
*/
|
|
22
|
+
| 'needs-import';
|
|
16
23
|
/** One planned edit: append `token` into the anchor array of `file`. */
|
|
17
24
|
export interface IWiringFixEdit {
|
|
18
25
|
readonly ruleId: string;
|
|
@@ -25,6 +32,14 @@ export interface IWiringFixEdit {
|
|
|
25
32
|
readonly insert: string;
|
|
26
33
|
/** The full new file contents, ready to write. */
|
|
27
34
|
readonly nextContent: string;
|
|
35
|
+
/**
|
|
36
|
+
* The import statement added alongside the array append, when the sink
|
|
37
|
+
* imports its members and the specifier was derivable. Absent when the token
|
|
38
|
+
* was already bound in the sink file.
|
|
39
|
+
*/
|
|
40
|
+
readonly importInsert?: string;
|
|
41
|
+
/** 1-based line the import statement lands on. */
|
|
42
|
+
readonly importLine?: number;
|
|
28
43
|
}
|
|
29
44
|
/** A violation the planner deliberately did not touch. */
|
|
30
45
|
export interface IWiringFixSkip {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plan-wiring-fix.d.ts","sourceRoot":"","sources":["../../src/wiring/plan-wiring-fix.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAiB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"plan-wiring-fix.d.ts","sourceRoot":"","sources":["../../src/wiring/plan-wiring-fix.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAiB,MAAM,gBAAgB,CAAC;AAIjE,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAWhF,eAAO,MAAM,iBAAiB,EAAG,0BAAmC,CAAC;AAErE,uDAAuD;AACvD,MAAM,MAAM,gBAAgB;AAC1B,mEAAmE;AACjE,gBAAgB;AAClB,iEAAiE;GAC/D,mBAAmB;AACrB,wDAAwD;GACtD,qBAAqB;AACvB,+DAA+D;GAC7D,iBAAiB;AACnB,+FAA+F;GAC7F,sBAAsB;AACxB;;;;;GAKG;GACD,cAAc,CAAC;AAEnB,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kDAAkD;IAClD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,kDAAkD;IAClD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,0DAA0D;AAC1D,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,OAAO,iBAAiB,CAAC;IAC1C,QAAQ,CAAC,KAAK,EAAE,SAAS,cAAc,EAAE,CAAC;IAC1C,QAAQ,CAAC,OAAO,EAAE,SAAS,cAAc,EAAE,CAAC;CAC7C;AAED,2CAA2C;AAC3C,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAuKD;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,WAAW,EACjB,UAAU,EAAE,SAAS,gBAAgB,EAAE,EACvC,KAAK,EAAE,SAAS,cAAc,EAAE,GAC/B,cAAc,CA2KhB"}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { matchesAny } from "../scan/glob.js";
|
|
2
|
-
import { escapeRegex, scanBalanced } from "../extract/scan-literals.js";
|
|
2
|
+
import { elementToken, escapeRegex, scanBalanced } from "../extract/scan-literals.js";
|
|
3
|
+
import { extractTokens } from "../extract/extract-tokens.js";
|
|
3
4
|
import { registeredSources } from "./evaluate-wiring.js";
|
|
5
|
+
import { collectSinkBindings, deriveImportTemplate, insertImportStatement, renderImport, renderSpecifier, } from "./sink-imports.js";
|
|
4
6
|
import { resolveExtractorAnchor, resolveExtractorKind } from '@shrkcrft/core';
|
|
5
7
|
export const WIRING_FIX_SCHEMA = 'sharkcraft.wiring-fix/v1';
|
|
6
8
|
/** Indentation of the line containing `index`. */
|
|
@@ -17,6 +19,134 @@ function lineOf(content, index) {
|
|
|
17
19
|
}
|
|
18
20
|
return line;
|
|
19
21
|
}
|
|
22
|
+
/** Extensions a derived specifier may resolve through, in probe order. */
|
|
23
|
+
const RESOLVE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a relative specifier against the sink's directory, POSIX-style.
|
|
26
|
+
*
|
|
27
|
+
* Only relative specifiers are resolvable this way. A bare specifier is a
|
|
28
|
+
* package (or a tsconfig alias) whose target this engine cannot know without a
|
|
29
|
+
* resolver, so those are reported unresolved and the caller refuses.
|
|
30
|
+
*/
|
|
31
|
+
function resolveRelative(sinkPath, specifier) {
|
|
32
|
+
if (!specifier.startsWith('.'))
|
|
33
|
+
return undefined;
|
|
34
|
+
const dir = sinkPath.includes('/') ? sinkPath.slice(0, sinkPath.lastIndexOf('/')) : '';
|
|
35
|
+
const parts = [...dir.split('/').filter(Boolean), ...specifier.split('/')];
|
|
36
|
+
const stack = [];
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
if (part === '' || part === '.')
|
|
39
|
+
continue;
|
|
40
|
+
if (part === '..') {
|
|
41
|
+
if (stack.length === 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
stack.pop();
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
stack.push(part);
|
|
47
|
+
}
|
|
48
|
+
return stack.join('/');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether a derived specifier really points at the file that DECLARES the
|
|
52
|
+
* token.
|
|
53
|
+
*
|
|
54
|
+
* This is the safety net that makes the derived-import path trustworthy rather
|
|
55
|
+
* than merely plausible. The template says what the specifier *should* be; this
|
|
56
|
+
* confirms it names the file the gate found the token in. A template that
|
|
57
|
+
* happens to fit the existing members but misses for the new one is caught
|
|
58
|
+
* here instead of being written to disk.
|
|
59
|
+
*/
|
|
60
|
+
function specifierPointsAtDeclaration(sinkPath, specifier, declaringFile) {
|
|
61
|
+
const base = resolveRelative(sinkPath, specifier);
|
|
62
|
+
if (base === undefined)
|
|
63
|
+
return false;
|
|
64
|
+
if (base === declaringFile)
|
|
65
|
+
return true;
|
|
66
|
+
for (const ext of RESOLVE_EXTENSIONS) {
|
|
67
|
+
if (base + ext === declaringFile)
|
|
68
|
+
return true;
|
|
69
|
+
if (`${base}/index${ext}` === declaringFile)
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether `file` exports `token`, per the same extractor the declared side of a
|
|
76
|
+
* rule most often uses.
|
|
77
|
+
*
|
|
78
|
+
* Reuses `extractTokens` rather than a second regex so "what counts as an
|
|
79
|
+
* export" has exactly one definition — a private answer here that drifted from
|
|
80
|
+
* the extractor would either block valid fixes or wave through invalid ones.
|
|
81
|
+
*/
|
|
82
|
+
function exportsToken(content, token) {
|
|
83
|
+
const res = extractTokens({ files: ['*'], extract: 'export-names' }, [{ path: 'x', content }]);
|
|
84
|
+
return res.sites.some((site) => site.token === token);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Decide whether appending `token` to the sink needs an import, and whether
|
|
88
|
+
* that import can be derived.
|
|
89
|
+
*
|
|
90
|
+
* The condition is an INCONSISTENCY check, not a resolvability check. An import
|
|
91
|
+
* is needed exactly when the sink's existing members are import-bound but the
|
|
92
|
+
* new token would not be — that is the case where the append introduces a
|
|
93
|
+
* reference the file cannot resolve, and where the gate would go green over a
|
|
94
|
+
* file that no longer compiles.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately NOT "the token must be bound". If the existing members are
|
|
97
|
+
* themselves unbound (ambient globals, a `/// <reference>`, a fixture), then
|
|
98
|
+
* whatever makes them resolve applies equally to the new one, and the append is
|
|
99
|
+
* consistent with the file's own convention. Refusing there would be a false
|
|
100
|
+
* refusal on a file the edit does not make any worse.
|
|
101
|
+
*/
|
|
102
|
+
function decideImport(sinkPath, sinkContent, members, token, declaringFile, declaringContent) {
|
|
103
|
+
const bindings = collectSinkBindings(sinkContent);
|
|
104
|
+
// Already resolvable here — imported, or declared inline in the sink.
|
|
105
|
+
if (bindings.some((b) => b.local === token))
|
|
106
|
+
return { kind: 'append-only' };
|
|
107
|
+
// Only members brought in by an IMPORT establish a binding convention the new
|
|
108
|
+
// token has to follow. A locally-declared member says nothing about where an
|
|
109
|
+
// external one would come from.
|
|
110
|
+
const importedMembers = members.filter((m) => bindings.some((b) => b.local === m && b.kind !== 'local'));
|
|
111
|
+
// No member arrives by import, so there is no convention the new token is
|
|
112
|
+
// breaking — the append leaves the file exactly as consistent as it was.
|
|
113
|
+
if (importedMembers.length === 0)
|
|
114
|
+
return { kind: 'append-only' };
|
|
115
|
+
const template = deriveImportTemplate(sinkContent, members);
|
|
116
|
+
if (!template) {
|
|
117
|
+
return {
|
|
118
|
+
kind: 'refuse',
|
|
119
|
+
detail: `sink ${sinkPath} imports its members, but their import paths are not a function of the member name ` +
|
|
120
|
+
'(a barrel, mixed paths, or an aliased import) — the specifier for this token cannot be derived',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (declaringFile === undefined) {
|
|
124
|
+
return {
|
|
125
|
+
kind: 'refuse',
|
|
126
|
+
detail: `sink ${sinkPath} imports its members and the declaring file of "${token}" is unknown, so the import cannot be verified`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// A named import only works if the target actually EXPORTS the token. The
|
|
130
|
+
// gate may have found it with any extractor (`regex-capture` over `const (\w+)`
|
|
131
|
+
// matches un-exported locals too), so being declared there does not imply
|
|
132
|
+
// being exported from there.
|
|
133
|
+
if (declaringContent !== undefined && !exportsToken(declaringContent, token)) {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'refuse',
|
|
136
|
+
detail: `"${token}" is declared in ${declaringFile} but not exported from it — ` +
|
|
137
|
+
'a named import would not resolve, so the array append is refused too',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const specifier = renderSpecifier(template, token);
|
|
141
|
+
if (!specifierPointsAtDeclaration(sinkPath, specifier, declaringFile)) {
|
|
142
|
+
return {
|
|
143
|
+
kind: 'refuse',
|
|
144
|
+
detail: `sink ${sinkPath} imports its members; the derived specifier "${specifier}" does not resolve to ` +
|
|
145
|
+
`${declaringFile}, where "${token}" is declared — refusing rather than writing a guess`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { kind: 'derived', template, statement: renderImport(template, token) };
|
|
149
|
+
}
|
|
20
150
|
/**
|
|
21
151
|
* Plan the mechanically-unambiguous fixes for `declared-but-not-registered`
|
|
22
152
|
* violations.
|
|
@@ -99,6 +229,25 @@ export function planWiringFix(rule, violations, files) {
|
|
|
99
229
|
}
|
|
100
230
|
const open = mm.index + mm[0].length - 1;
|
|
101
231
|
const scan = scanBalanced(content, open);
|
|
232
|
+
// Decide the IMPORT before touching the array. An append that leaves the
|
|
233
|
+
// token unbound compiles-fails, and a gate that goes green by breaking the
|
|
234
|
+
// build is worse than one that refuses — so a token we cannot bind is
|
|
235
|
+
// skipped with the reason, never half-written.
|
|
236
|
+
const currentMembers = scan.elements
|
|
237
|
+
.map((el) => elementToken(el.text))
|
|
238
|
+
.filter((t) => t !== undefined);
|
|
239
|
+
const decision = quoted
|
|
240
|
+
? { kind: 'append-only' } // string members bind nothing
|
|
241
|
+
: decideImport(target.path, content, currentMembers, v.token, v.file, v.file ? files.find((f) => f.path === v.file)?.content : undefined);
|
|
242
|
+
if (decision.kind === 'refuse') {
|
|
243
|
+
skipped.push({
|
|
244
|
+
ruleId: rule.id,
|
|
245
|
+
token: v.token,
|
|
246
|
+
reason: 'needs-import',
|
|
247
|
+
detail: decision.detail,
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
102
251
|
insertAt = scan.end;
|
|
103
252
|
const last = scan.elements[scan.elements.length - 1];
|
|
104
253
|
const indent = last ? indentOf(content, last.index) : ' ';
|
|
@@ -116,13 +265,37 @@ export function planWiringFix(rule, violations, files) {
|
|
|
116
265
|
? `${needsComma ? ',' : ''}\n${indent}${rendered},`
|
|
117
266
|
: `${needsComma ? ', ' : ''}${rendered}`;
|
|
118
267
|
content = content.slice(0, cut) + insert + tail + content.slice(insertAt);
|
|
268
|
+
const arrayLine = lineOf(content, cut + insert.length);
|
|
269
|
+
// Add the derived import LAST, so the array line number reported above is
|
|
270
|
+
// computed before the insertion shifts it — the two edits are reported
|
|
271
|
+
// against the file the reviewer will actually see.
|
|
272
|
+
let importInsert;
|
|
273
|
+
let importLine;
|
|
274
|
+
if (decision.kind === 'derived') {
|
|
275
|
+
const inserted = insertImportStatement(content, decision.statement);
|
|
276
|
+
if (!inserted) {
|
|
277
|
+
// Unreachable in practice (a template implies an import exists), but a
|
|
278
|
+
// planner that silently dropped the import would reintroduce the bug.
|
|
279
|
+
skipped.push({
|
|
280
|
+
ruleId: rule.id,
|
|
281
|
+
token: v.token,
|
|
282
|
+
reason: 'needs-import',
|
|
283
|
+
detail: `could not locate an import block in ${target.path} to extend`,
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
content = inserted.nextContent;
|
|
288
|
+
importInsert = decision.statement;
|
|
289
|
+
importLine = inserted.line;
|
|
290
|
+
}
|
|
119
291
|
edits.push({
|
|
120
292
|
ruleId: rule.id,
|
|
121
293
|
token: v.token,
|
|
122
294
|
file: target.path,
|
|
123
|
-
line:
|
|
295
|
+
line: arrayLine,
|
|
124
296
|
insert: insert.trim(),
|
|
125
297
|
nextContent: content,
|
|
298
|
+
...(importInsert !== undefined ? { importInsert, importLine: importLine } : {}),
|
|
126
299
|
});
|
|
127
300
|
}
|
|
128
301
|
return { schema: WIRING_FIX_SCHEMA, edits, skipped };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type IRegistrationIdiom } from '@shrkcrft/core';
|
|
2
2
|
import { type IWiringFileEntry } from './evaluate-wiring.js';
|
|
3
3
|
export declare const REGISTRATION_GRAPH_SCHEMA: "sharkcraft.registration-graph/v1";
|
|
4
4
|
/** A token role-site: which idiom + file:line it was found at. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registration-graph.d.ts","sourceRoot":"","sources":["../../src/wiring/registration-graph.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"registration-graph.d.ts","sourceRoot":"","sources":["../../src/wiring/registration-graph.ts"],"names":[],"mappings":"AAGA,OAAO,EAAsB,KAAK,kBAAkB,EAAsB,MAAM,gBAAgB,CAAC;AAGjG,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEjF,eAAO,MAAM,yBAAyB,EAAG,kCAA2C,CAAC;AAErF,kEAAkE;AAClE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,wEAAwE;AACxE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAChD,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAChD,oDAAoD;IACpD,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACjD;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,MAAM,EAAE,OAAO,yBAAyB,CAAC;IAClD,kDAAkD;IAClD,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,yDAAyD;IACzD,QAAQ,CAAC,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC9C,6EAA6E;IAC7E,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;CACzC;AAED,MAAM,WAAW,8BAA8B;IAC7C,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAqBD;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,SAAS,kBAAkB,EAAE,EACrC,OAAO,GAAE,8BAAmC,GAC3C,kBAAkB,CAqDpB;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,SAAS,kBAAkB,EAAE,EACrC,OAAO,GAAE,8BAAmC,GAC3C,MAAM,CAiBR;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,SAAS,kBAAkB,EAAE,EACrC,OAAO,EAAE,SAAS,gBAAgB,EAAE,GACnC,GAAG,CAAC,MAAM,CAAC,CAOb;AAED,mEAAmE;AACnE,MAAM,WAAW,kBAAmB,SAAQ,iBAAiB;IAC3D,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,kBAAkB,EACzB,KAAK,EAAE,MAAM,GACZ,kBAAkB,GAAG,SAAS,CAShC;AAED,oFAAoF;AACpF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAChD,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACjD;AAeD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,kBAAkB,EACzB,YAAY,EAAE,SAAS,MAAM,EAAE,GAC9B,OAAO,CAMT;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,kBAAkB,EACzB,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,GAC/B,SAAS,gBAAgB,EAAE,CAM7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACjD;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,kBAAkB,EACzB,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,GAC/B,SAAS,mBAAmB,EAAE,CAMhC"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { statSync } from 'node:fs';
|
|
3
3
|
import * as nodePath from 'node:path';
|
|
4
|
+
import { resolveSourceGlobs } from '@shrkcrft/core';
|
|
4
5
|
import { matchesAny } from "../scan/glob.js";
|
|
5
6
|
import { readMatchingFiles, walkMatching } from "../util/walk-files.js";
|
|
6
7
|
import { collectSourceSites } from "./evaluate-wiring.js";
|
|
@@ -11,7 +12,11 @@ function sortSites(sites) {
|
|
|
11
12
|
/** Union (deduped) of every glob any role of any idiom references. */
|
|
12
13
|
function registrationGlobs(idioms) {
|
|
13
14
|
return [
|
|
14
|
-
...new Set(idioms.flatMap((i) => [
|
|
15
|
+
...new Set(idioms.flatMap((i) => [
|
|
16
|
+
...resolveSourceGlobs(i.declared),
|
|
17
|
+
...resolveSourceGlobs(i.provided),
|
|
18
|
+
...resolveSourceGlobs(i.consumed),
|
|
19
|
+
])),
|
|
15
20
|
];
|
|
16
21
|
}
|
|
17
22
|
/**
|
|
@@ -28,7 +33,7 @@ export function buildRegistrationGraph(projectRoot, idioms, options = {}) {
|
|
|
28
33
|
path,
|
|
29
34
|
content,
|
|
30
35
|
}));
|
|
31
|
-
const filesFor = (source) => entries.filter((f) => matchesAny(f.path, source
|
|
36
|
+
const filesFor = (source) => entries.filter((f) => matchesAny(f.path, resolveSourceGlobs(source)));
|
|
32
37
|
const declared = new Map();
|
|
33
38
|
const provided = new Map();
|
|
34
39
|
const consumed = new Map();
|
|
@@ -96,7 +101,7 @@ export function registrationGraphSignature(projectRoot, idioms, options = {}) {
|
|
|
96
101
|
export function providedTokensFromEntries(idioms, entries) {
|
|
97
102
|
const tokens = new Set();
|
|
98
103
|
for (const idiom of idioms) {
|
|
99
|
-
const files = entries.filter((f) => matchesAny(f.path, idiom.provided
|
|
104
|
+
const files = entries.filter((f) => matchesAny(f.path, resolveSourceGlobs(idiom.provided)));
|
|
100
105
|
for (const s of collectSourceSites(idiom.provided, files).sites)
|
|
101
106
|
tokens.add(s.token);
|
|
102
107
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type IRegistryDeclaration } from '@shrkcrft/core';
|
|
2
2
|
export declare const REGISTRY_SCHEMA: "sharkcraft.registry-inventory/v1";
|
|
3
3
|
/** Where an id was found (project-relative path + 1-based line). */
|
|
4
4
|
export interface IRegistrySite {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry-query.d.ts","sourceRoot":"","sources":["../../src/wiring/registry-query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"registry-query.d.ts","sourceRoot":"","sources":["../../src/wiring/registry-query.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAK/E,eAAO,MAAM,eAAe,EAAG,kCAA2C,CAAC;AAE3E,oEAAoE;AACpE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,CAAC;IACzC,+EAA+E;IAC/E,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;CACnD;AAED,0EAA0E;AAC1E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,MAAM,EAAE,OAAO,eAAe,CAAC;IACxC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,SAAS,cAAc,EAAE,CAAC;IAC5C,6EAA6E;IAC7E,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;CACzC;AAED,MAAM,WAAW,oBAAoB;IACnC,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAMD;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,oBAAoB,EAC1B,OAAO,GAAE,oBAAyB,GACjC,kBAAkB,CAkDpB;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,SAAS,EAAE,kBAAkB,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAEjF;AAED,wDAAwD;AACxD,wBAAgB,aAAa,CAAC,SAAS,EAAE,kBAAkB,EAAE,EAAE,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAEnG;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,kBAAkB,GAAG,SAAS,cAAc,EAAE,CAE3F"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveSourceGlobs } from '@shrkcrft/core';
|
|
1
2
|
import { matchesAny } from "../scan/glob.js";
|
|
2
3
|
import { readMatchingFiles } from "../util/walk-files.js";
|
|
3
4
|
import { collectSourceSites } from "./evaluate-wiring.js";
|
|
@@ -12,18 +13,21 @@ function sortSites(sites) {
|
|
|
12
13
|
*/
|
|
13
14
|
export function scanRegistry(projectRoot, decl, options = {}) {
|
|
14
15
|
const globs = [
|
|
15
|
-
...new Set([
|
|
16
|
+
...new Set([
|
|
17
|
+
...resolveSourceGlobs(decl.source),
|
|
18
|
+
...(decl.consumer ? resolveSourceGlobs(decl.consumer) : []),
|
|
19
|
+
]),
|
|
16
20
|
];
|
|
17
21
|
const cache = readMatchingFiles(projectRoot, globs, new Set(options.excludeDirs ?? []));
|
|
18
22
|
const entries = [...cache.entries()].map(([path, content]) => ({ path, content }));
|
|
19
23
|
const diagnostics = [];
|
|
20
|
-
const sourceFiles = entries.filter((f) => matchesAny(f.path, decl.source
|
|
24
|
+
const sourceFiles = entries.filter((f) => matchesAny(f.path, resolveSourceGlobs(decl.source)));
|
|
21
25
|
const declared = collectSourceSites(decl.source, sourceFiles);
|
|
22
26
|
if (declared.error)
|
|
23
27
|
diagnostics.push(`registry "${decl.name}" source: ${declared.error}`);
|
|
24
28
|
const consumerByToken = new Map();
|
|
25
29
|
if (decl.consumer) {
|
|
26
|
-
const consumerFiles = entries.filter((f) => matchesAny(f.path, decl.consumer
|
|
30
|
+
const consumerFiles = entries.filter((f) => matchesAny(f.path, resolveSourceGlobs(decl.consumer)));
|
|
27
31
|
const consumed = collectSourceSites(decl.consumer, consumerFiles);
|
|
28
32
|
if (consumed.error)
|
|
29
33
|
diagnostics.push(`registry "${decl.name}" consumer: ${consumed.error}`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type IWiringRule } from '@shrkcrft/core';
|
|
2
2
|
import { type IWiringReport } from './evaluate-wiring.js';
|
|
3
3
|
export interface IRunWiringOptions {
|
|
4
4
|
/** Only run rules touched by these (project-relative) changed files. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scan-wiring-files.d.ts","sourceRoot":"","sources":["../../src/wiring/scan-wiring-files.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"scan-wiring-files.d.ts","sourceRoot":"","sources":["../../src/wiring/scan-wiring-files.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAItE,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,sBAAsB,CAAC;AAO9B,MAAM,WAAW,iBAAiB;IAChC,wEAAwE;IACxE,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,iGAAiG;IACjG,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,+BAA+B;IAC/B,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,2FAA2F;IAC3F,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,SAAS,WAAW,EAAE,EAC7B,OAAO,GAAE,iBAAsB,GAC9B,aAAa,CAqCf"}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { resolveSourceGlobs } from '@shrkcrft/core';
|
|
1
2
|
import { matchesAny } from "../scan/glob.js";
|
|
2
3
|
import { readMatchingFiles } from "../util/walk-files.js";
|
|
4
|
+
import { loadTsconfigPaths } from "../scan/tsconfig-aliases.js";
|
|
3
5
|
import { evaluateWiring, wiringGlobsOf, } from "./evaluate-wiring.js";
|
|
4
6
|
/** All file globs a rule references: every side / every hop. */
|
|
5
7
|
function ruleGlobs(rule) {
|
|
@@ -38,5 +40,7 @@ export function runWiring(projectRoot, rules, options = {}) {
|
|
|
38
40
|
const allGlobs = [...new Set(selected.flatMap(ruleGlobs))];
|
|
39
41
|
const cache = readMatchingFiles(projectRoot, allGlobs, new Set(options.excludeDirs ?? []));
|
|
40
42
|
const entries = [...cache.entries()].map(([path, content]) => ({ path, content }));
|
|
41
|
-
|
|
43
|
+
// The tsconfig paths let an `import-edges` source resolve alias specifiers the
|
|
44
|
+
// way the compiler does — the same map `check boundaries` resolves with.
|
|
45
|
+
return evaluateWiring(selected, (source) => entries.filter((f) => matchesAny(f.path, resolveSourceGlobs(source))), { tsconfigPaths: loadTsconfigPaths(projectRoot) });
|
|
42
46
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import analysis for a wiring SINK file.
|
|
3
|
+
*
|
|
4
|
+
* `check wiring --fix` appends a token to a registry array. That edit is only
|
|
5
|
+
* safe when the token actually RESOLVES in the sink file. The common registry
|
|
6
|
+
* shape imports every member:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { ALPHA_HANDLER } from './ALPHA_HANDLER';
|
|
10
|
+
* export const HANDLERS = [ALPHA_HANDLER];
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Appending `NEW_HANDLER` to that array turns the wiring gate GREEN while
|
|
14
|
+
* leaving the file referencing an unbound symbol — it no longer compiles. A
|
|
15
|
+
* gate that goes green by breaking the build is the exact inverse of the
|
|
16
|
+
* point, so the planner has to know what the sink file binds before it writes.
|
|
17
|
+
*
|
|
18
|
+
* This is a lexer, not a compiler: it answers "is this name bound in this
|
|
19
|
+
* file?" and "do the existing members follow one derivable import pattern?" —
|
|
20
|
+
* both of which are decidable from the text, and neither of which is guessed.
|
|
21
|
+
*/
|
|
22
|
+
/** How a name entered the file's scope. */
|
|
23
|
+
export type SinkBindingKind = 'named-import' | 'default-import' | 'namespace-import' | 'local';
|
|
24
|
+
/** One value binding visible in the sink file. */
|
|
25
|
+
export interface ISinkBinding {
|
|
26
|
+
/** The name as used in the file (the local name, after any `as` alias). */
|
|
27
|
+
readonly local: string;
|
|
28
|
+
readonly kind: SinkBindingKind;
|
|
29
|
+
/** The exported name, for a named import (differs from `local` when aliased). */
|
|
30
|
+
readonly imported?: string;
|
|
31
|
+
/** Module specifier, for an import binding. */
|
|
32
|
+
readonly specifier?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Every value name bound in the file: imports plus its own top-level
|
|
36
|
+
* declarations.
|
|
37
|
+
*
|
|
38
|
+
* Local declarations count because a registry that declares its members inline
|
|
39
|
+
* needs no import at all — refusing to fix those would be a false refusal.
|
|
40
|
+
*/
|
|
41
|
+
export declare function collectSinkBindings(content: string): ISinkBinding[];
|
|
42
|
+
/** Whether `name` already resolves in the sink file. */
|
|
43
|
+
export declare function isBoundInSink(content: string, name: string): boolean;
|
|
44
|
+
/** A uniform import pattern the existing members all follow. */
|
|
45
|
+
export interface IImportTemplate {
|
|
46
|
+
/** The specifier with every occurrence of the member name replaced by `{}`. */
|
|
47
|
+
readonly specifierTemplate: string;
|
|
48
|
+
/** Quote character used by the existing imports. */
|
|
49
|
+
readonly quote: string;
|
|
50
|
+
/** Whether existing import statements end in a semicolon. */
|
|
51
|
+
readonly semicolon: boolean;
|
|
52
|
+
/** How many members the template was derived from. */
|
|
53
|
+
readonly derivedFrom: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Derive the import pattern the sink's existing members follow, if there is
|
|
57
|
+
* exactly one.
|
|
58
|
+
*
|
|
59
|
+
* Derivation succeeds only when the specifier is a pure FUNCTION OF THE MEMBER
|
|
60
|
+
* NAME — every member `M` imported as `import { M } from '<something>M<something>'`
|
|
61
|
+
* with the same surrounding text. A barrel (`from './handlers'`), mixed paths,
|
|
62
|
+
* or an aliased import make the next specifier unknowable, and the planner must
|
|
63
|
+
* never invent a path: those return `undefined` so the caller refuses instead.
|
|
64
|
+
*/
|
|
65
|
+
export declare function deriveImportTemplate(content: string, members: readonly string[]): IImportTemplate | undefined;
|
|
66
|
+
/** Render the import statement this template produces for `token`. */
|
|
67
|
+
export declare function renderImport(template: IImportTemplate, token: string): string;
|
|
68
|
+
/** The specifier a template yields for `token`, without the surrounding syntax. */
|
|
69
|
+
export declare function renderSpecifier(template: IImportTemplate, token: string): string;
|
|
70
|
+
/** Where a new import line goes, and the resulting content. */
|
|
71
|
+
export interface IImportInsertion {
|
|
72
|
+
readonly nextContent: string;
|
|
73
|
+
/** 1-based line the inserted statement lands on. */
|
|
74
|
+
readonly line: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Insert an import statement after the LAST existing import.
|
|
78
|
+
*
|
|
79
|
+
* Appending to the import block (rather than sorting into it) keeps the edit a
|
|
80
|
+
* pure insertion: no existing line moves, so the diff shows exactly one added
|
|
81
|
+
* line and a reviewer can see the whole change at a glance.
|
|
82
|
+
*/
|
|
83
|
+
export declare function insertImportStatement(content: string, statement: string): IImportInsertion | undefined;
|
|
84
|
+
//# sourceMappingURL=sink-imports.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sink-imports.d.ts","sourceRoot":"","sources":["../../src/wiring/sink-imports.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,2CAA2C;AAC3C,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,gBAAgB,GAAG,kBAAkB,GAAG,OAAO,CAAC;AAE/F,kDAAkD;AAClD,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,+CAA+C;IAC/C,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAMD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,EAAE,CAsBnE;AAED,wDAAwD;AACxD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEpE;AAED,gEAAgE;AAChE,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,oDAAoD;IACpD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,sDAAsD;IACtD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,eAAe,GAAG,SAAS,CA6B7B;AAED,sEAAsE;AACtE,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAG7E;AAED,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhF;AAED,+DAA+D;AAC/D,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAChB,gBAAgB,GAAG,SAAS,CAe9B"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { lineOf } from "../extract/scan-literals.js";
|
|
2
|
+
import { parseImportStatements } from "../extract/parse-imports.js";
|
|
3
|
+
/** Top-level value/type declarations — the names a file binds itself. */
|
|
4
|
+
const LOCAL_DECL = /(?:^|\n)\s*(?:export\s+)?(?:declare\s+)?(?:default\s+)?(?:const|let|var|function\s*\*?|class|enum|interface|type)\s+([A-Za-z_$][\w$]*)/g;
|
|
5
|
+
/**
|
|
6
|
+
* Every value name bound in the file: imports plus its own top-level
|
|
7
|
+
* declarations.
|
|
8
|
+
*
|
|
9
|
+
* Local declarations count because a registry that declares its members inline
|
|
10
|
+
* needs no import at all — refusing to fix those would be a false refusal.
|
|
11
|
+
*/
|
|
12
|
+
export function collectSinkBindings(content) {
|
|
13
|
+
const bindings = [];
|
|
14
|
+
for (const statement of parseImportStatements(content)) {
|
|
15
|
+
// A type-only import binds no VALUE, so it can never make an array element
|
|
16
|
+
// resolve. Counting it would let the planner write a reference that erases
|
|
17
|
+
// at compile time.
|
|
18
|
+
if (statement.typeOnly)
|
|
19
|
+
continue;
|
|
20
|
+
for (const b of statement.bindings) {
|
|
21
|
+
bindings.push({
|
|
22
|
+
local: b.local,
|
|
23
|
+
kind: `${b.kind}-import`,
|
|
24
|
+
...(b.imported !== undefined ? { imported: b.imported } : {}),
|
|
25
|
+
specifier: statement.specifier,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
LOCAL_DECL.lastIndex = 0;
|
|
30
|
+
let m;
|
|
31
|
+
while ((m = LOCAL_DECL.exec(content)) !== null) {
|
|
32
|
+
bindings.push({ local: m[1], kind: 'local' });
|
|
33
|
+
}
|
|
34
|
+
return bindings;
|
|
35
|
+
}
|
|
36
|
+
/** Whether `name` already resolves in the sink file. */
|
|
37
|
+
export function isBoundInSink(content, name) {
|
|
38
|
+
return collectSinkBindings(content).some((b) => b.local === name);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Derive the import pattern the sink's existing members follow, if there is
|
|
42
|
+
* exactly one.
|
|
43
|
+
*
|
|
44
|
+
* Derivation succeeds only when the specifier is a pure FUNCTION OF THE MEMBER
|
|
45
|
+
* NAME — every member `M` imported as `import { M } from '<something>M<something>'`
|
|
46
|
+
* with the same surrounding text. A barrel (`from './handlers'`), mixed paths,
|
|
47
|
+
* or an aliased import make the next specifier unknowable, and the planner must
|
|
48
|
+
* never invent a path: those return `undefined` so the caller refuses instead.
|
|
49
|
+
*/
|
|
50
|
+
export function deriveImportTemplate(content, members) {
|
|
51
|
+
const bindings = collectSinkBindings(content);
|
|
52
|
+
const byLocal = new Map(bindings.map((b) => [b.local, b]));
|
|
53
|
+
let template;
|
|
54
|
+
let derivedFrom = 0;
|
|
55
|
+
for (const member of members) {
|
|
56
|
+
const binding = byLocal.get(member);
|
|
57
|
+
if (!binding || binding.kind === 'local')
|
|
58
|
+
continue;
|
|
59
|
+
// An alias means the local name is NOT the exported name, so the next
|
|
60
|
+
// token's exported name cannot be derived from the name we would write.
|
|
61
|
+
if (binding.kind !== 'named-import' || binding.imported !== binding.local)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (!binding.specifier || !binding.specifier.includes(member))
|
|
64
|
+
return undefined;
|
|
65
|
+
const candidate = binding.specifier.split(member).join('{}');
|
|
66
|
+
if (template === undefined)
|
|
67
|
+
template = candidate;
|
|
68
|
+
else if (template !== candidate)
|
|
69
|
+
return undefined;
|
|
70
|
+
derivedFrom += 1;
|
|
71
|
+
}
|
|
72
|
+
if (template === undefined || derivedFrom === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
// Mirror the file's existing punctuation so the inserted line does not fight
|
|
75
|
+
// the formatter that will run over it next.
|
|
76
|
+
const raw = parseImportStatements(content).find((st) => st.kind === 'import')?.raw ?? '';
|
|
77
|
+
return {
|
|
78
|
+
specifierTemplate: template,
|
|
79
|
+
quote: raw.includes('"') ? '"' : "'",
|
|
80
|
+
semicolon: raw.trimEnd().endsWith(';'),
|
|
81
|
+
derivedFrom,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Render the import statement this template produces for `token`. */
|
|
85
|
+
export function renderImport(template, token) {
|
|
86
|
+
const specifier = template.specifierTemplate.split('{}').join(token);
|
|
87
|
+
return `import { ${token} } from ${template.quote}${specifier}${template.quote}${template.semicolon ? ';' : ''}`;
|
|
88
|
+
}
|
|
89
|
+
/** The specifier a template yields for `token`, without the surrounding syntax. */
|
|
90
|
+
export function renderSpecifier(template, token) {
|
|
91
|
+
return template.specifierTemplate.split('{}').join(token);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Insert an import statement after the LAST existing import.
|
|
95
|
+
*
|
|
96
|
+
* Appending to the import block (rather than sorting into it) keeps the edit a
|
|
97
|
+
* pure insertion: no existing line moves, so the diff shows exactly one added
|
|
98
|
+
* line and a reviewer can see the whole change at a glance.
|
|
99
|
+
*/
|
|
100
|
+
export function insertImportStatement(content, statement) {
|
|
101
|
+
// Only the STATIC import block is a valid insertion point: landing after a
|
|
102
|
+
// dynamic `import()` deep in the file would put a top-level import in the
|
|
103
|
+
// middle of a function body.
|
|
104
|
+
const statements = parseImportStatements(content).filter((st) => st.kind === 'import');
|
|
105
|
+
const last = statements[statements.length - 1];
|
|
106
|
+
if (!last)
|
|
107
|
+
return undefined;
|
|
108
|
+
const endOfLast = last.index + last.raw.length;
|
|
109
|
+
// Land immediately after the statement's own newline so the insertion never
|
|
110
|
+
// splits a line, whatever trails the specifier.
|
|
111
|
+
const newline = content.indexOf('\n', endOfLast);
|
|
112
|
+
const at = newline === -1 ? content.length : newline + 1;
|
|
113
|
+
const nextContent = content.slice(0, at) + statement + '\n' + content.slice(at);
|
|
114
|
+
return { nextContent, line: lineOf(nextContent, at) };
|
|
115
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shrkcrft/boundaries",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.30",
|
|
4
4
|
"description": "SharkCraft boundary rules: detect when a repository violates its own architecture (forbidden imports across folder/package/layer boundaries).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "SharkCraft contributors",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@shrkcrft/core": "^0.1.0-alpha.
|
|
46
|
+
"@shrkcrft/core": "^0.1.0-alpha.30"
|
|
47
47
|
},
|
|
48
48
|
"publishConfig": {
|
|
49
49
|
"access": "public"
|