@shrkcrft/boundaries 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/dist/evaluate/evaluate-boundaries.d.ts +48 -0
- package/dist/evaluate/evaluate-boundaries.d.ts.map +1 -0
- package/dist/evaluate/evaluate-boundaries.js +96 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/model/boundary-rule.d.ts +38 -0
- package/dist/model/boundary-rule.d.ts.map +1 -0
- package/dist/model/boundary-rule.js +30 -0
- package/dist/registry/boundary-registry.d.ts +11 -0
- package/dist/registry/boundary-registry.d.ts.map +1 -0
- package/dist/registry/boundary-registry.js +22 -0
- package/dist/registry/load-boundary-rules.d.ts +12 -0
- package/dist/registry/load-boundary-rules.d.ts.map +1 -0
- package/dist/registry/load-boundary-rules.js +44 -0
- package/dist/scan/glob.d.ts +13 -0
- package/dist/scan/glob.d.ts.map +1 -0
- package/dist/scan/glob.js +55 -0
- package/dist/scan/scan-imports.d.ts +46 -0
- package/dist/scan/scan-imports.d.ts.map +1 -0
- package/dist/scan/scan-imports.js +148 -0
- package/dist/scan/tsconfig-aliases.d.ts +34 -0
- package/dist/scan/tsconfig-aliases.d.ts.map +1 -0
- package/dist/scan/tsconfig-aliases.js +126 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SharkCraft contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @shrkcrft/boundaries
|
|
2
|
+
|
|
3
|
+
SharkCraft boundary rules: detect when a repository violates its own architecture (forbidden imports across folder/package/layer boundaries).
|
|
4
|
+
|
|
5
|
+
Part of [SharkCraft](https://github.com/shrkcrft/sharkcraft) — a deterministic, local-first toolkit that gives AI coding agents durable project context. See the main repo for documentation, examples, and the `shrk` CLI.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @shrkcrft/boundaries
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## License
|
|
14
|
+
|
|
15
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { IBoundaryRule, BoundarySeverity } from '../model/boundary-rule.js';
|
|
2
|
+
import type { IImportScanResult } from '../scan/scan-imports.js';
|
|
3
|
+
import { type ITsconfigPathsMap } from '../scan/tsconfig-aliases.js';
|
|
4
|
+
export interface IBoundaryViolation {
|
|
5
|
+
ruleId: string;
|
|
6
|
+
ruleTitle: string;
|
|
7
|
+
severity: BoundarySeverity;
|
|
8
|
+
file: string;
|
|
9
|
+
importSpecifier: string;
|
|
10
|
+
line: number;
|
|
11
|
+
/** Pattern from `from` that matched the file. */
|
|
12
|
+
matchedFrom: string;
|
|
13
|
+
/** Pattern from `forbiddenImports` that matched the specifier, if any. */
|
|
14
|
+
matchedForbidden?: string;
|
|
15
|
+
/** When triggered by allowedImports = [..] (none matched). */
|
|
16
|
+
notAllowed?: boolean;
|
|
17
|
+
/** When the match came from an alias-resolved candidate path. */
|
|
18
|
+
resolvedVia?: string;
|
|
19
|
+
message: string;
|
|
20
|
+
suggestedFix?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface IEvaluateOptions {
|
|
23
|
+
/** Optional filter: only evaluate rules with this id. */
|
|
24
|
+
onlyRuleId?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Optional tsconfig paths map. When supplied, every edge specifier is also
|
|
27
|
+
* resolved against the alias map and the resulting candidate paths are
|
|
28
|
+
* matched against rule patterns alongside the original specifier.
|
|
29
|
+
*/
|
|
30
|
+
tsconfigPaths?: ITsconfigPathsMap;
|
|
31
|
+
}
|
|
32
|
+
export interface IEvaluateResult {
|
|
33
|
+
rulesEvaluated: number;
|
|
34
|
+
edgesEvaluated: number;
|
|
35
|
+
violations: IBoundaryViolation[];
|
|
36
|
+
/** Quick counts grouped by severity. */
|
|
37
|
+
counts: {
|
|
38
|
+
error: number;
|
|
39
|
+
warning: number;
|
|
40
|
+
info: number;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Evaluate every rule against every scanned edge. Returns the list of
|
|
45
|
+
* violations + counts. Pure function — no I/O.
|
|
46
|
+
*/
|
|
47
|
+
export declare function evaluateBoundaries(scan: IImportScanResult, rules: readonly IBoundaryRule[], options?: IEvaluateOptions): IEvaluateResult;
|
|
48
|
+
//# sourceMappingURL=evaluate-boundaries.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"evaluate-boundaries.d.ts","sourceRoot":"","sources":["../../src/evaluate/evaluate-boundaries.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAEjF,OAAO,KAAK,EAAe,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC9E,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,6BAA6B,CAAC;AAErC,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,WAAW,EAAE,MAAM,CAAC;IACpB,0EAA0E;IAC1E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,8DAA8D;IAC9D,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,aAAa,CAAC,EAAE,iBAAiB,CAAC;CACnC;AAED,MAAM,WAAW,eAAe;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,kBAAkB,EAAE,CAAC;IACjC,wCAAwC;IACxC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1D;AAMD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,iBAAiB,EACvB,KAAK,EAAE,SAAS,aAAa,EAAE,EAC/B,OAAO,GAAE,gBAAqB,GAC7B,eAAe,CA6DjB"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { matchesAny } from "../scan/glob.js";
|
|
2
|
+
import { resolveAliasCandidates, } from "../scan/tsconfig-aliases.js";
|
|
3
|
+
function severityOf(rule) {
|
|
4
|
+
return rule.severity ?? 'error';
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Evaluate every rule against every scanned edge. Returns the list of
|
|
8
|
+
* violations + counts. Pure function — no I/O.
|
|
9
|
+
*/
|
|
10
|
+
export function evaluateBoundaries(scan, rules, options = {}) {
|
|
11
|
+
const filtered = options.onlyRuleId
|
|
12
|
+
? rules.filter((r) => r.id === options.onlyRuleId)
|
|
13
|
+
: rules;
|
|
14
|
+
const violations = [];
|
|
15
|
+
for (const rule of filtered) {
|
|
16
|
+
for (const edge of scan.edges) {
|
|
17
|
+
const matchedFrom = firstMatch(edge.from, rule.from);
|
|
18
|
+
if (!matchedFrom)
|
|
19
|
+
continue;
|
|
20
|
+
// Build the candidate list: the literal specifier + any tsconfig
|
|
21
|
+
// alias resolutions. The first match wins; resolved paths give the
|
|
22
|
+
// boundary rule a chance to match against `libs/app/adapter/**`
|
|
23
|
+
// even when the source code wrote `@app/adapter-core`.
|
|
24
|
+
const specifiers = [edge.importSpecifier];
|
|
25
|
+
if (options.tsconfigPaths) {
|
|
26
|
+
for (const resolved of resolveAliasCandidates(edge.importSpecifier, options.tsconfigPaths)) {
|
|
27
|
+
specifiers.push(resolved);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Forbidden imports — match any candidate.
|
|
31
|
+
let matchedForbidden;
|
|
32
|
+
let matchedVia;
|
|
33
|
+
if (rule.forbiddenImports) {
|
|
34
|
+
for (const spec of specifiers) {
|
|
35
|
+
const m = firstMatch(spec, rule.forbiddenImports);
|
|
36
|
+
if (m) {
|
|
37
|
+
matchedForbidden = m;
|
|
38
|
+
matchedVia = spec !== edge.importSpecifier ? spec : undefined;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (matchedForbidden) {
|
|
44
|
+
const v = violationFor(rule, edge, matchedFrom, matchedForbidden);
|
|
45
|
+
if (matchedVia)
|
|
46
|
+
v.resolvedVia = matchedVia;
|
|
47
|
+
violations.push(v);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// Allowed imports — when set, anything NOT matching (any candidate)
|
|
51
|
+
// is a violation.
|
|
52
|
+
if (rule.allowedImports && rule.allowedImports.length > 0) {
|
|
53
|
+
const matchedAllowed = specifiers.some((s) => matchesAny(s, rule.allowedImports));
|
|
54
|
+
if (!matchedAllowed && !edge.importSpecifier.startsWith('.')) {
|
|
55
|
+
violations.push({
|
|
56
|
+
...violationFor(rule, edge, matchedFrom, undefined),
|
|
57
|
+
notAllowed: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
64
|
+
for (const v of violations)
|
|
65
|
+
counts[v.severity] += 1;
|
|
66
|
+
return {
|
|
67
|
+
rulesEvaluated: filtered.length,
|
|
68
|
+
edgesEvaluated: scan.edges.length,
|
|
69
|
+
violations,
|
|
70
|
+
counts,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function firstMatch(value, patterns) {
|
|
74
|
+
for (const p of patterns) {
|
|
75
|
+
if (matchesAny(value, [p]))
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
function violationFor(rule, edge, matchedFrom, matchedForbidden) {
|
|
81
|
+
return {
|
|
82
|
+
ruleId: rule.id,
|
|
83
|
+
ruleTitle: rule.title,
|
|
84
|
+
severity: severityOf(rule),
|
|
85
|
+
file: edge.from,
|
|
86
|
+
importSpecifier: edge.importSpecifier,
|
|
87
|
+
line: edge.line,
|
|
88
|
+
matchedFrom,
|
|
89
|
+
...(matchedForbidden ? { matchedForbidden } : {}),
|
|
90
|
+
message: rule.message ??
|
|
91
|
+
(matchedForbidden
|
|
92
|
+
? `Forbidden import in ${edge.from}: "${edge.importSpecifier}" matched "${matchedForbidden}"`
|
|
93
|
+
: `Import "${edge.importSpecifier}" not in allowed list for ${rule.id}`),
|
|
94
|
+
...(rule.suggestedFix ? { suggestedFix: rule.suggestedFix } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './model/boundary-rule.js';
|
|
2
|
+
export * from './registry/boundary-registry.js';
|
|
3
|
+
export * from './registry/load-boundary-rules.js';
|
|
4
|
+
export * from './scan/glob.js';
|
|
5
|
+
export * from './scan/scan-imports.js';
|
|
6
|
+
export * from './evaluate/evaluate-boundaries.js';
|
|
7
|
+
export * from './scan/tsconfig-aliases.js';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,mCAAmC,CAAC;AAClD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mCAAmC,CAAC;AAClD,cAAc,4BAA4B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./model/boundary-rule.js";
|
|
2
|
+
export * from "./registry/boundary-registry.js";
|
|
3
|
+
export * from "./registry/load-boundary-rules.js";
|
|
4
|
+
export * from "./scan/glob.js";
|
|
5
|
+
export * from "./scan/scan-imports.js";
|
|
6
|
+
export * from "./evaluate/evaluate-boundaries.js";
|
|
7
|
+
export * from "./scan/tsconfig-aliases.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type BoundarySeverity = 'error' | 'warning' | 'info';
|
|
2
|
+
export interface IBoundaryRule {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
severity?: BoundarySeverity;
|
|
7
|
+
/**
|
|
8
|
+
* Glob patterns describing which files the rule applies to. Matched against
|
|
9
|
+
* the file path relative to the project root.
|
|
10
|
+
*/
|
|
11
|
+
from: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Glob patterns describing imports that are forbidden from `from` files.
|
|
14
|
+
* Matched against the literal import specifier.
|
|
15
|
+
*/
|
|
16
|
+
forbiddenImports?: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Optional whitelist of allowed imports (when set, non-matching imports
|
|
19
|
+
* also trigger the rule). Useful for "from X, only @x/y is allowed".
|
|
20
|
+
*/
|
|
21
|
+
allowedImports?: readonly string[];
|
|
22
|
+
tags?: readonly string[];
|
|
23
|
+
appliesWhen?: readonly string[];
|
|
24
|
+
message?: string;
|
|
25
|
+
suggestedFix?: string;
|
|
26
|
+
relatedRules?: readonly string[];
|
|
27
|
+
relatedPathConventions?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function defineBoundaryRule<T extends IBoundaryRule>(rule: T): T;
|
|
30
|
+
export interface IBoundaryRuleValidationIssue {
|
|
31
|
+
field: string;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function validateBoundaryRule(value: unknown): {
|
|
35
|
+
valid: boolean;
|
|
36
|
+
issues: IBoundaryRuleValidationIssue[];
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=boundary-rule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"boundary-rule.d.ts","sourceRoot":"","sources":["../../src/model/boundary-rule.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;AAE5D,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B;;;OAGG;IACH,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;OAGG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5C;AAED,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,aAAa,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,CAEtE;AAED,MAAM,WAAW,4BAA4B;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAID,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG;IACpD,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,4BAA4B,EAAE,CAAC;CACxC,CAyBA"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function defineBoundaryRule(rule) {
|
|
2
|
+
return rule;
|
|
3
|
+
}
|
|
4
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
5
|
+
export function validateBoundaryRule(value) {
|
|
6
|
+
const issues = [];
|
|
7
|
+
if (!value || typeof value !== 'object') {
|
|
8
|
+
return {
|
|
9
|
+
valid: false,
|
|
10
|
+
issues: [{ field: '<root>', message: 'rule must be an object' }],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const r = value;
|
|
14
|
+
if (typeof r.id !== 'string' || !ID_PATTERN.test(r.id)) {
|
|
15
|
+
issues.push({ field: 'id', message: 'id required, slug-style' });
|
|
16
|
+
}
|
|
17
|
+
if (typeof r.title !== 'string' || r.title.length === 0) {
|
|
18
|
+
issues.push({ field: 'title', message: 'title required' });
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(r.from) || r.from.length === 0) {
|
|
21
|
+
issues.push({ field: 'from', message: 'from must be a non-empty string array' });
|
|
22
|
+
}
|
|
23
|
+
if (!Array.isArray(r.forbiddenImports) && !Array.isArray(r.allowedImports)) {
|
|
24
|
+
issues.push({
|
|
25
|
+
field: 'forbiddenImports|allowedImports',
|
|
26
|
+
message: 'either forbiddenImports or allowedImports must be set',
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return { valid: issues.length === 0, issues };
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IBoundaryRule } from '../model/boundary-rule.js';
|
|
2
|
+
export declare class BoundaryRegistry {
|
|
3
|
+
private readonly byId;
|
|
4
|
+
constructor(rules?: readonly IBoundaryRule[]);
|
|
5
|
+
add(rule: IBoundaryRule): void;
|
|
6
|
+
has(id: string): boolean;
|
|
7
|
+
get(id: string): IBoundaryRule | undefined;
|
|
8
|
+
list(): readonly IBoundaryRule[];
|
|
9
|
+
size(): number;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=boundary-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"boundary-registry.d.ts","sourceRoot":"","sources":["../../src/registry/boundary-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE/D,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAoC;gBAE7C,KAAK,GAAE,SAAS,aAAa,EAAO;IAGhD,GAAG,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI;IAG9B,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;IAGxB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;IAG1C,IAAI,IAAI,SAAS,aAAa,EAAE;IAGhC,IAAI,IAAI,MAAM;CAGf"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class BoundaryRegistry {
|
|
2
|
+
byId = new Map();
|
|
3
|
+
constructor(rules = []) {
|
|
4
|
+
for (const r of rules)
|
|
5
|
+
this.add(r);
|
|
6
|
+
}
|
|
7
|
+
add(rule) {
|
|
8
|
+
this.byId.set(rule.id, rule);
|
|
9
|
+
}
|
|
10
|
+
has(id) {
|
|
11
|
+
return this.byId.has(id);
|
|
12
|
+
}
|
|
13
|
+
get(id) {
|
|
14
|
+
return this.byId.get(id);
|
|
15
|
+
}
|
|
16
|
+
list() {
|
|
17
|
+
return [...this.byId.values()];
|
|
18
|
+
}
|
|
19
|
+
size() {
|
|
20
|
+
return this.byId.size;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type IImportContext } from '@shrkcrft/core';
|
|
2
|
+
import { type IBoundaryRule } from '../model/boundary-rule.js';
|
|
3
|
+
export interface ILoadedBoundaryRulesFile {
|
|
4
|
+
source: string;
|
|
5
|
+
rules: IBoundaryRule[];
|
|
6
|
+
warnings: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface ILoadBoundaryRulesOptions {
|
|
9
|
+
importContext?: IImportContext;
|
|
10
|
+
}
|
|
11
|
+
export declare function loadBoundaryRulesFromFile(absPath: string, options?: ILoadBoundaryRulesOptions): Promise<ILoadedBoundaryRulesFile>;
|
|
12
|
+
//# sourceMappingURL=load-boundary-rules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"load-boundary-rules.d.ts","sourceRoot":"","sources":["../../src/registry/load-boundary-rules.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,cAAc,EAAc,MAAM,gBAAgB,CAAC;AACjE,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAErF,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,yBAAyB;IACxC,aAAa,CAAC,EAAE,cAAc,CAAC;CAChC;AAED,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,yBAA8B,GACtC,OAAO,CAAC,wBAAwB,CAAC,CA0CnC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { safeImport } from '@shrkcrft/core';
|
|
3
|
+
import { validateBoundaryRule } from "../model/boundary-rule.js";
|
|
4
|
+
export async function loadBoundaryRulesFromFile(absPath, options = {}) {
|
|
5
|
+
const out = {
|
|
6
|
+
source: absPath,
|
|
7
|
+
rules: [],
|
|
8
|
+
warnings: [],
|
|
9
|
+
};
|
|
10
|
+
if (!existsSync(absPath)) {
|
|
11
|
+
out.warnings.push(`boundary rules file not found: ${absPath}`);
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
const result = options.importContext
|
|
15
|
+
? await options.importContext.load(absPath)
|
|
16
|
+
: await safeImport(absPath, {
|
|
17
|
+
skipExistsCheck: true,
|
|
18
|
+
});
|
|
19
|
+
if (!result.ok) {
|
|
20
|
+
const label = result.timedOut
|
|
21
|
+
? 'timed out loading boundary rules from'
|
|
22
|
+
: 'failed to load boundary rules from';
|
|
23
|
+
out.warnings.push(`${label} ${absPath}: ${result.error.message}`);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
const candidates = pickArray(result.module.default) ??
|
|
27
|
+
pickArray(result.module.rules) ??
|
|
28
|
+
pickArray(result.module.boundaries) ??
|
|
29
|
+
[];
|
|
30
|
+
for (const c of candidates) {
|
|
31
|
+
const v = validateBoundaryRule(c);
|
|
32
|
+
if (!v.valid) {
|
|
33
|
+
out.warnings.push(`${absPath}: skipping invalid boundary rule (${v.issues.map((i) => i.field).join(', ')})`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
out.rules.push(c);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function pickArray(v) {
|
|
41
|
+
if (Array.isArray(v))
|
|
42
|
+
return v;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal glob matcher tuned for the patterns boundary rules use:
|
|
3
|
+
* - `**` matches any number of path segments (including zero)
|
|
4
|
+
* - `*` matches any chars except `/`
|
|
5
|
+
* - `?` matches a single char except `/`
|
|
6
|
+
* - everything else is literal
|
|
7
|
+
*
|
|
8
|
+
* Patterns are matched against the literal string (file path or import
|
|
9
|
+
* specifier) — no I/O, no resolution. The function is pure and deterministic.
|
|
10
|
+
*/
|
|
11
|
+
export declare function globToRegex(pattern: string): RegExp;
|
|
12
|
+
export declare function matchesAny(value: string, patterns: readonly string[]): boolean;
|
|
13
|
+
//# sourceMappingURL=glob.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glob.d.ts","sourceRoot":"","sources":["../../src/scan/glob.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAmCnD;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAK9E"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal glob matcher tuned for the patterns boundary rules use:
|
|
3
|
+
* - `**` matches any number of path segments (including zero)
|
|
4
|
+
* - `*` matches any chars except `/`
|
|
5
|
+
* - `?` matches a single char except `/`
|
|
6
|
+
* - everything else is literal
|
|
7
|
+
*
|
|
8
|
+
* Patterns are matched against the literal string (file path or import
|
|
9
|
+
* specifier) — no I/O, no resolution. The function is pure and deterministic.
|
|
10
|
+
*/
|
|
11
|
+
export function globToRegex(pattern) {
|
|
12
|
+
let r = '';
|
|
13
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
14
|
+
const ch = pattern[i];
|
|
15
|
+
if (ch === '*') {
|
|
16
|
+
// ** vs *
|
|
17
|
+
const next = pattern[i + 1];
|
|
18
|
+
if (next === '*') {
|
|
19
|
+
// ** — zero or more segments
|
|
20
|
+
// Also consume a trailing `/` so `a/** /b` matches `a/b`.
|
|
21
|
+
const after = pattern[i + 2];
|
|
22
|
+
if (after === '/') {
|
|
23
|
+
r += '(?:.*/)?';
|
|
24
|
+
i += 2;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
r += '.*';
|
|
28
|
+
i += 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
r += '[^/]*';
|
|
33
|
+
}
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (ch === '?') {
|
|
37
|
+
r += '[^/]';
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// Escape regex special chars.
|
|
41
|
+
if ('.+^$|(){}[]\\'.includes(ch)) {
|
|
42
|
+
r += '\\' + ch;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
r += ch;
|
|
46
|
+
}
|
|
47
|
+
return new RegExp('^' + r + '$');
|
|
48
|
+
}
|
|
49
|
+
export function matchesAny(value, patterns) {
|
|
50
|
+
for (const p of patterns) {
|
|
51
|
+
if (globToRegex(p).test(value))
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface IScanImportsOptions {
|
|
2
|
+
projectRoot: string;
|
|
3
|
+
extraIgnore?: readonly string[];
|
|
4
|
+
/** When set, only files under one of these globs are scanned. */
|
|
5
|
+
include?: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
export interface IImportEdge {
|
|
8
|
+
/** Source file (relative to projectRoot). */
|
|
9
|
+
from: string;
|
|
10
|
+
/** Literal import specifier. */
|
|
11
|
+
importSpecifier: string;
|
|
12
|
+
/** Approximate 1-based line number in the source file. */
|
|
13
|
+
line: number;
|
|
14
|
+
/**
|
|
15
|
+
* Heuristic resolution. v1 sets:
|
|
16
|
+
* - 'internal' if the specifier starts with './' or '../'
|
|
17
|
+
* - 'external' otherwise
|
|
18
|
+
* (We do not attempt tsconfig path-mapping resolution here.)
|
|
19
|
+
*/
|
|
20
|
+
kind: 'internal' | 'external';
|
|
21
|
+
}
|
|
22
|
+
export interface IImportScanResult {
|
|
23
|
+
filesScanned: number;
|
|
24
|
+
edges: IImportEdge[];
|
|
25
|
+
warnings: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Walk the project root and return every detected import edge.
|
|
29
|
+
*/
|
|
30
|
+
export declare function scanImports(options: IScanImportsOptions): IImportScanResult;
|
|
31
|
+
/**
|
|
32
|
+
* Aggregate summary for `shrk graph imports` / MCP get_import_graph_summary.
|
|
33
|
+
*/
|
|
34
|
+
export interface IImportGraphSummary {
|
|
35
|
+
filesScanned: number;
|
|
36
|
+
totalImports: number;
|
|
37
|
+
internalImports: number;
|
|
38
|
+
externalImports: number;
|
|
39
|
+
topExternalSpecifiers: readonly {
|
|
40
|
+
specifier: string;
|
|
41
|
+
count: number;
|
|
42
|
+
}[];
|
|
43
|
+
warnings: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
export declare function summarizeImports(scan: IImportScanResult): IImportGraphSummary;
|
|
46
|
+
//# sourceMappingURL=scan-imports.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scan-imports.d.ts","sourceRoot":"","sources":["../../src/scan/scan-imports.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,iEAAiE;IACjE,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,WAAW;IAC1B,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,eAAe,EAAE,MAAM,CAAC;IACxB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,IAAI,EAAE,UAAU,GAAG,UAAU,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAoFD;;GAEG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,mBAAmB,GAAG,iBAAiB,CAyB3E;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,qBAAqB,EAAE,SAAS;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB,GAAG,mBAAmB,CAmB7E"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import * as nodePath from 'node:path';
|
|
3
|
+
const SUPPORTED_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
4
|
+
const DEFAULT_IGNORE = new Set([
|
|
5
|
+
'node_modules',
|
|
6
|
+
'dist',
|
|
7
|
+
'build',
|
|
8
|
+
'coverage',
|
|
9
|
+
'.git',
|
|
10
|
+
'.sharkcraft',
|
|
11
|
+
'.next',
|
|
12
|
+
'.cache',
|
|
13
|
+
'.tmp-pack',
|
|
14
|
+
'.tmp-smoke-consumer.txt',
|
|
15
|
+
]);
|
|
16
|
+
// Match `import ... from 'x'`, `export ... from 'x'`, `require('x')`,
|
|
17
|
+
// `import('x')` (static and dynamic). Captures the specifier as group 1.
|
|
18
|
+
//
|
|
19
|
+
// We DELIBERATELY use a single regex per kind rather than a real parser —
|
|
20
|
+
// boundary rules need stable behavior across syntaxes and a regex scanner is
|
|
21
|
+
// the simplest thing that works for v1. Comments and string escapes can fool
|
|
22
|
+
// it; we filter the lowest-hanging fruit (single-line // and /* */ stripped
|
|
23
|
+
// per line, but a `// import "x"` line is still ignored).
|
|
24
|
+
const IMPORT_RE = /(?:^|\s)(?:import|export)\s+[^'"`]*?from\s+['"]([^'"`]+)['"]/g;
|
|
25
|
+
const SIDE_EFFECT_IMPORT_RE = /(?:^|\s)import\s+['"]([^'"`]+)['"]/g;
|
|
26
|
+
const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"`]+)['"]\s*\)/g;
|
|
27
|
+
const REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"`]+)['"]\s*\)/g;
|
|
28
|
+
function isIgnored(name, extraIgnore) {
|
|
29
|
+
if (DEFAULT_IGNORE.has(name))
|
|
30
|
+
return true;
|
|
31
|
+
if (extraIgnore.has(name))
|
|
32
|
+
return true;
|
|
33
|
+
if (name.startsWith('.'))
|
|
34
|
+
return name !== '.';
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
function* walk(root, current, extraIgnore) {
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const name = String(entry.name);
|
|
47
|
+
if (isIgnored(name, extraIgnore))
|
|
48
|
+
continue;
|
|
49
|
+
const full = nodePath.join(current, name);
|
|
50
|
+
try {
|
|
51
|
+
const isDir = entry.isDirectory() || entry.isSymbolicLink();
|
|
52
|
+
if (isDir) {
|
|
53
|
+
const stat = statSync(full);
|
|
54
|
+
if (stat.isDirectory()) {
|
|
55
|
+
yield* walk(root, full, extraIgnore);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (entry.isFile()) {
|
|
64
|
+
const ext = nodePath.extname(name);
|
|
65
|
+
if (!SUPPORTED_EXTS.has(ext))
|
|
66
|
+
continue;
|
|
67
|
+
yield full;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function lineFor(source, offset) {
|
|
72
|
+
let line = 1;
|
|
73
|
+
for (let i = 0; i < offset && i < source.length; i += 1) {
|
|
74
|
+
if (source[i] === '\n')
|
|
75
|
+
line += 1;
|
|
76
|
+
}
|
|
77
|
+
return line;
|
|
78
|
+
}
|
|
79
|
+
function extractImports(source, relPath) {
|
|
80
|
+
const edges = [];
|
|
81
|
+
for (const re of [IMPORT_RE, SIDE_EFFECT_IMPORT_RE, DYNAMIC_IMPORT_RE, REQUIRE_RE]) {
|
|
82
|
+
re.lastIndex = 0;
|
|
83
|
+
let m;
|
|
84
|
+
while ((m = re.exec(source)) !== null) {
|
|
85
|
+
const spec = m[1];
|
|
86
|
+
const line = lineFor(source, m.index);
|
|
87
|
+
edges.push({
|
|
88
|
+
from: relPath,
|
|
89
|
+
importSpecifier: spec,
|
|
90
|
+
line,
|
|
91
|
+
kind: spec.startsWith('.') ? 'internal' : 'external',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return edges;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Walk the project root and return every detected import edge.
|
|
99
|
+
*/
|
|
100
|
+
export function scanImports(options) {
|
|
101
|
+
const root = nodePath.resolve(options.projectRoot);
|
|
102
|
+
const extraIgnore = new Set(options.extraIgnore ?? []);
|
|
103
|
+
const result = {
|
|
104
|
+
filesScanned: 0,
|
|
105
|
+
edges: [],
|
|
106
|
+
warnings: [],
|
|
107
|
+
};
|
|
108
|
+
if (!existsSync(root)) {
|
|
109
|
+
result.warnings.push(`scan root does not exist: ${root}`);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
for (const file of walk(root, root, extraIgnore)) {
|
|
113
|
+
result.filesScanned += 1;
|
|
114
|
+
const rel = nodePath.relative(root, file);
|
|
115
|
+
let source;
|
|
116
|
+
try {
|
|
117
|
+
source = readFileSync(file, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
result.warnings.push(`unreadable: ${rel} (${e.message})`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
result.edges.push(...extractImports(source, rel));
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
export function summarizeImports(scan) {
|
|
128
|
+
const externalCounts = new Map();
|
|
129
|
+
let internal = 0;
|
|
130
|
+
for (const e of scan.edges) {
|
|
131
|
+
if (e.kind === 'internal')
|
|
132
|
+
internal += 1;
|
|
133
|
+
else
|
|
134
|
+
externalCounts.set(e.importSpecifier, (externalCounts.get(e.importSpecifier) ?? 0) + 1);
|
|
135
|
+
}
|
|
136
|
+
const top = [...externalCounts.entries()]
|
|
137
|
+
.sort((a, b) => b[1] - a[1])
|
|
138
|
+
.slice(0, 10)
|
|
139
|
+
.map(([specifier, count]) => ({ specifier, count }));
|
|
140
|
+
return {
|
|
141
|
+
filesScanned: scan.filesScanned,
|
|
142
|
+
totalImports: scan.edges.length,
|
|
143
|
+
internalImports: internal,
|
|
144
|
+
externalImports: scan.edges.length - internal,
|
|
145
|
+
topExternalSpecifiers: top,
|
|
146
|
+
warnings: scan.warnings,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal tsconfig paths resolver. We read tsconfig.json and tsconfig.base.json
|
|
3
|
+
* from the project root, then build a map of import-specifier patterns to
|
|
4
|
+
* project-relative target paths. The resolver supports:
|
|
5
|
+
*
|
|
6
|
+
* - Exact aliases: "@scope/x": ["packages/x/src/index.ts"]
|
|
7
|
+
* - Wildcard aliases: "@scope/*": ["packages/*\/src/index.ts"]
|
|
8
|
+
*
|
|
9
|
+
* v1 does NOT resolve node_modules, .d.ts vs .js variants, or index lookups.
|
|
10
|
+
* The result is a list of *candidate paths*; the boundary evaluator matches
|
|
11
|
+
* those candidates against `from` patterns just like literal specifiers.
|
|
12
|
+
*/
|
|
13
|
+
export interface ITsconfigPathsMap {
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
/** Each alias maps to one or more target patterns. */
|
|
16
|
+
aliases: ReadonlyMap<string, readonly string[]>;
|
|
17
|
+
/** Source files actually read (for cache invalidation / debug). */
|
|
18
|
+
sources: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Read tsconfig(.base).json from `projectRoot` and return a normalized map of
|
|
22
|
+
* paths aliases. Both files are tried; entries from tsconfig.json win on
|
|
23
|
+
* conflict.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadTsconfigPaths(projectRoot: string): ITsconfigPathsMap;
|
|
26
|
+
/**
|
|
27
|
+
* Given an import specifier, return the list of project-relative target paths
|
|
28
|
+
* the tsconfig paths map would resolve it to.
|
|
29
|
+
*
|
|
30
|
+
* Returns an empty array when no alias matches. The original specifier is NOT
|
|
31
|
+
* included; the caller is expected to keep using both.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveAliasCandidates(specifier: string, map: ITsconfigPathsMap): string[];
|
|
34
|
+
//# sourceMappingURL=tsconfig-aliases.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tsconfig-aliases.d.ts","sourceRoot":"","sources":["../../src/scan/tsconfig-aliases.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IAChD,mEAAmE;IACnE,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5B;AA2DD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,iBAAiB,CA2BxE;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,iBAAiB,GACrB,MAAM,EAAE,CAaV"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import * as nodePath from 'node:path';
|
|
3
|
+
function readJsonRelaxed(p) {
|
|
4
|
+
// tsconfig allows // line comments, /* block comments */, and trailing
|
|
5
|
+
// commas. The naive regex approach mangles real string values that happen
|
|
6
|
+
// to contain "/*" or "//" (e.g. wildcard paths like "packages/*/src/index.ts"
|
|
7
|
+
// or URL fragments). Walk char-by-char and track string state so we only
|
|
8
|
+
// strip syntactic comments + trailing commas, never content inside strings.
|
|
9
|
+
try {
|
|
10
|
+
const raw = readFileSync(p, 'utf8');
|
|
11
|
+
const out = [];
|
|
12
|
+
let i = 0;
|
|
13
|
+
let inString = false;
|
|
14
|
+
let stringQuote = '';
|
|
15
|
+
let escape = false;
|
|
16
|
+
while (i < raw.length) {
|
|
17
|
+
const ch = raw[i];
|
|
18
|
+
if (inString) {
|
|
19
|
+
out.push(ch);
|
|
20
|
+
if (escape) {
|
|
21
|
+
escape = false;
|
|
22
|
+
}
|
|
23
|
+
else if (ch === '\\') {
|
|
24
|
+
escape = true;
|
|
25
|
+
}
|
|
26
|
+
else if (ch === stringQuote) {
|
|
27
|
+
inString = false;
|
|
28
|
+
}
|
|
29
|
+
i += 1;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch === '"' || ch === "'") {
|
|
33
|
+
inString = true;
|
|
34
|
+
stringQuote = ch;
|
|
35
|
+
out.push(ch);
|
|
36
|
+
i += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Line comment
|
|
40
|
+
if (ch === '/' && raw[i + 1] === '/') {
|
|
41
|
+
while (i < raw.length && raw[i] !== '\n')
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// Block comment
|
|
46
|
+
if (ch === '/' && raw[i + 1] === '*') {
|
|
47
|
+
i += 2;
|
|
48
|
+
while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/'))
|
|
49
|
+
i += 1;
|
|
50
|
+
i += 2;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
out.push(ch);
|
|
54
|
+
i += 1;
|
|
55
|
+
}
|
|
56
|
+
// Remove trailing commas before } or ]. Safe outside strings now.
|
|
57
|
+
const trimmed = out.join('').replace(/,(\s*[}\]])/g, '$1');
|
|
58
|
+
return JSON.parse(trimmed);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Read tsconfig(.base).json from `projectRoot` and return a normalized map of
|
|
66
|
+
* paths aliases. Both files are tried; entries from tsconfig.json win on
|
|
67
|
+
* conflict.
|
|
68
|
+
*/
|
|
69
|
+
export function loadTsconfigPaths(projectRoot) {
|
|
70
|
+
const sources = [];
|
|
71
|
+
const aliases = new Map();
|
|
72
|
+
let baseUrl;
|
|
73
|
+
const candidates = ['tsconfig.base.json', 'tsconfig.json'];
|
|
74
|
+
for (const name of candidates) {
|
|
75
|
+
const full = nodePath.join(projectRoot, name);
|
|
76
|
+
if (!existsSync(full))
|
|
77
|
+
continue;
|
|
78
|
+
const json = readJsonRelaxed(full);
|
|
79
|
+
if (!json?.compilerOptions)
|
|
80
|
+
continue;
|
|
81
|
+
sources.push(full);
|
|
82
|
+
if (json.compilerOptions.baseUrl && !baseUrl)
|
|
83
|
+
baseUrl = json.compilerOptions.baseUrl;
|
|
84
|
+
for (const [k, v] of Object.entries(json.compilerOptions.paths ?? {})) {
|
|
85
|
+
if (!aliases.has(k))
|
|
86
|
+
aliases.set(k, v);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
baseUrl: baseUrl ?? '.',
|
|
91
|
+
aliases,
|
|
92
|
+
sources,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Given an import specifier, return the list of project-relative target paths
|
|
97
|
+
* the tsconfig paths map would resolve it to.
|
|
98
|
+
*
|
|
99
|
+
* Returns an empty array when no alias matches. The original specifier is NOT
|
|
100
|
+
* included; the caller is expected to keep using both.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveAliasCandidates(specifier, map) {
|
|
103
|
+
// Exact alias first.
|
|
104
|
+
const exact = map.aliases.get(specifier);
|
|
105
|
+
if (exact)
|
|
106
|
+
return [...exact.map((t) => normalize(map.baseUrl, t))];
|
|
107
|
+
// Wildcard alias: pattern ends with `*`.
|
|
108
|
+
for (const [pattern, targets] of map.aliases) {
|
|
109
|
+
if (!pattern.endsWith('*'))
|
|
110
|
+
continue;
|
|
111
|
+
const prefix = pattern.slice(0, -1);
|
|
112
|
+
if (!specifier.startsWith(prefix))
|
|
113
|
+
continue;
|
|
114
|
+
const suffix = specifier.slice(prefix.length);
|
|
115
|
+
return targets.map((t) => normalize(map.baseUrl, t.replace('*', suffix)));
|
|
116
|
+
}
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
function normalize(baseUrl, target) {
|
|
120
|
+
// Strip leading "./" and join with baseUrl if non-trivial.
|
|
121
|
+
let t = target.replace(/^\.\//, '');
|
|
122
|
+
if (baseUrl && baseUrl !== '.' && baseUrl !== './') {
|
|
123
|
+
t = nodePath.posix.join(baseUrl.replace(/^\.\//, ''), t);
|
|
124
|
+
}
|
|
125
|
+
return t;
|
|
126
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shrkcrft/boundaries",
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
|
+
"description": "SharkCraft boundary rules: detect when a repository violates its own architecture (forbidden imports across folder/package/layer boundaries).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "SharkCraft contributors",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"bun": "./src/index.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/shrkcrft/sharkcraft.git",
|
|
26
|
+
"directory": "packages/boundaries"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/shrkcrft/sharkcraft",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/shrkcrft/sharkcraft/issues"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"sharkcraft",
|
|
34
|
+
"boundaries",
|
|
35
|
+
"architecture",
|
|
36
|
+
"imports"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"bun": ">=1.1.0",
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@shrkcrft/core": "^0.1.0-alpha.2"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|