@shrkcrft/boundaries 0.1.0-alpha.23 → 0.1.0-alpha.24

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/index.d.ts CHANGED
@@ -7,7 +7,10 @@ export * from './evaluate/evaluate-boundaries.js';
7
7
  export * from './scan/tsconfig-aliases.js';
8
8
  export * from './wiring/evaluate-wiring.js';
9
9
  export * from './wiring/scan-wiring-files.js';
10
+ export * from './wiring/explain-wiring.js';
10
11
  export * from './wiring/registry-query.js';
12
+ export * from './wiring/registration-graph.js';
13
+ export * from './wiring/trace-literal.js';
11
14
  export * from './policy/extract-templates.js';
12
15
  export * from './policy/evaluate-policy.js';
13
16
  export * from './policy/run-policy.js';
@@ -1 +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;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,wBAAwB,CAAC;AACvC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC"}
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;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,wBAAwB,CAAC;AACvC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC"}
package/dist/index.js CHANGED
@@ -7,7 +7,10 @@ export * from "./evaluate/evaluate-boundaries.js";
7
7
  export * from "./scan/tsconfig-aliases.js";
8
8
  export * from "./wiring/evaluate-wiring.js";
9
9
  export * from "./wiring/scan-wiring-files.js";
10
+ export * from "./wiring/explain-wiring.js";
10
11
  export * from "./wiring/registry-query.js";
12
+ export * from "./wiring/registration-graph.js";
13
+ export * from "./wiring/trace-literal.js";
11
14
  export * from "./policy/extract-templates.js";
12
15
  export * from "./policy/evaluate-policy.js";
13
16
  export * from "./policy/run-policy.js";
@@ -0,0 +1,53 @@
1
+ import type { IWiringRule } from '@shrkcrft/core';
2
+ import { type IWiringTokenSite } from './evaluate-wiring.js';
3
+ export declare const WIRING_EXPLAIN_SCHEMA: "sharkcraft.wiring-explain/v1";
4
+ /** One side (declared or registered) of a wiring rule, as extracted from the tree. */
5
+ export interface IWiringSideExplain {
6
+ /** Every capture site (token + file:line), stable-sorted by (file, line). */
7
+ readonly sites: readonly IWiringTokenSite[];
8
+ /** Distinct membership-key count (mirrors the gate's `declared/registered N`). */
9
+ readonly distinctCount: number;
10
+ /** Files scanned for this side (after glob resolution). */
11
+ readonly filesScanned: number;
12
+ /** Misconfiguration (bad regex / no capture group / bad source), if any. */
13
+ readonly error?: string;
14
+ }
15
+ /**
16
+ * The full intermediate output of evaluating ONE wiring rule against the live
17
+ * tree: the declared set and the registered set each source extracted (with
18
+ * file:line), plus the set-difference and verdict — the thing {@link
19
+ * evaluateWiring} computes internally but only emits as counts + violations.
20
+ */
21
+ export interface IWiringExplain {
22
+ readonly schema: typeof WIRING_EXPLAIN_SCHEMA;
23
+ readonly ruleId: string;
24
+ readonly description?: string;
25
+ readonly mode: 'subset' | 'parity';
26
+ readonly groupBy?: 'dir' | 'package';
27
+ readonly severity: 'error' | 'warning';
28
+ readonly declared: IWiringSideExplain;
29
+ readonly registered: IWiringSideExplain;
30
+ /** Declared tokens absent from the registered set (the `declared-missing` diff). */
31
+ readonly declaredNotRegistered: readonly IWiringTokenSite[];
32
+ /** Registered tokens absent from the declared set (parity-only `registered-missing`). */
33
+ readonly registeredNotDeclared: readonly IWiringTokenSite[];
34
+ readonly verdict: 'pass' | 'errors' | 'warnings';
35
+ /** Rule-level misconfiguration messages (engine degrades gracefully). */
36
+ readonly diagnostics: readonly string[];
37
+ }
38
+ export interface IExplainWiringOptions {
39
+ /** Project-relative directories to prune from the walk. */
40
+ readonly excludeDirs?: readonly string[];
41
+ }
42
+ /**
43
+ * Dry-run a single wiring rule against the live tree and return what each side
44
+ * extracted (declared set, registered set, the set-difference, the verdict) —
45
+ * WITHOUT writing config. Powers `wiring explain <ruleId>`, `wiring test
46
+ * <candidate>`, and `check wiring --explain <ruleId>`: the author can SEE the
47
+ * alias-resolved cross-file set-difference the gate computes before committing
48
+ * a rule. The diff/verdict reuse {@link evaluateWiring} so they match the gate
49
+ * exactly (incl. `groupBy` membership); the full per-site lists are extracted
50
+ * with the shared {@link collectSourceSites}. Never throws.
51
+ */
52
+ export declare function explainWiring(projectRoot: string, rule: IWiringRule, options?: IExplainWiringOptions): IWiringExplain;
53
+ //# sourceMappingURL=explain-wiring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"explain-wiring.d.ts","sourceRoot":"","sources":["../../src/wiring/explain-wiring.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAiB,MAAM,gBAAgB,CAAC;AAGjE,OAAO,EAIL,KAAK,gBAAgB,EACtB,MAAM,sBAAsB,CAAC;AAE9B,eAAO,MAAM,qBAAqB,EAAG,8BAAuC,CAAC;AAE7E,sFAAsF;AACtF,MAAM,WAAW,kBAAkB;IACjC,6EAA6E;IAC7E,QAAQ,CAAC,KAAK,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC5C,kFAAkF;IAClF,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,2DAA2D;IAC3D,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,OAAO,qBAAqB,CAAC;IAC9C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACnC,QAAQ,CAAC,OAAO,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,QAAQ,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,CAAC;IACtC,QAAQ,CAAC,UAAU,EAAE,kBAAkB,CAAC;IACxC,oFAAoF;IACpF,QAAQ,CAAC,qBAAqB,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC5D,yFAAyF;IACzF,QAAQ,CAAC,qBAAqB,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC5D,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;IACjD,yEAAyE;IACzE,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;CACzC;AAED,MAAM,WAAW,qBAAqB;IACpC,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAYD;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAC3B,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,WAAW,EACjB,OAAO,GAAE,qBAA0B,GAClC,cAAc,CAmEhB"}
@@ -0,0 +1,81 @@
1
+ import { matchesAny } from "../scan/glob.js";
2
+ import { readMatchingFiles } from "../util/walk-files.js";
3
+ import { collectSourceSites, evaluateWiring, } from "./evaluate-wiring.js";
4
+ export const WIRING_EXPLAIN_SCHEMA = 'sharkcraft.wiring-explain/v1';
5
+ function registeredSources(reg) {
6
+ return Array.isArray(reg) ? reg : [reg];
7
+ }
8
+ function sortSites(sites) {
9
+ return [...sites].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.token.localeCompare(b.token));
10
+ }
11
+ /**
12
+ * Dry-run a single wiring rule against the live tree and return what each side
13
+ * extracted (declared set, registered set, the set-difference, the verdict) —
14
+ * WITHOUT writing config. Powers `wiring explain <ruleId>`, `wiring test
15
+ * <candidate>`, and `check wiring --explain <ruleId>`: the author can SEE the
16
+ * alias-resolved cross-file set-difference the gate computes before committing
17
+ * a rule. The diff/verdict reuse {@link evaluateWiring} so they match the gate
18
+ * exactly (incl. `groupBy` membership); the full per-site lists are extracted
19
+ * with the shared {@link collectSourceSites}. Never throws.
20
+ */
21
+ export function explainWiring(projectRoot, rule, options = {}) {
22
+ const regSources = registeredSources(rule.registered);
23
+ const allGlobs = [
24
+ ...new Set([...rule.declared.files, ...regSources.flatMap((s) => [...s.files])]),
25
+ ];
26
+ const cache = readMatchingFiles(projectRoot, allGlobs, new Set(options.excludeDirs ?? []));
27
+ const entries = [...cache.entries()].map(([path, content]) => ({
28
+ path,
29
+ content,
30
+ }));
31
+ const filesFor = (source) => entries.filter((f) => matchesAny(f.path, source.files));
32
+ // Declared side — full sites.
33
+ const declaredFiles = filesFor(rule.declared);
34
+ const declaredRes = collectSourceSites(rule.declared, declaredFiles);
35
+ // Registered side — union of every source, full sites.
36
+ const registeredFiles = new Set();
37
+ const registeredSites = [];
38
+ let registeredError;
39
+ for (const source of regSources) {
40
+ const files = filesFor(source);
41
+ for (const f of files)
42
+ registeredFiles.add(f.path);
43
+ const res = collectSourceSites(source, files);
44
+ if (res.error && !registeredError)
45
+ registeredError = res.error;
46
+ registeredSites.push(...res.sites);
47
+ }
48
+ // Canonical diff + counts + verdict from the gate engine (same groupBy logic).
49
+ const report = evaluateWiring([rule], filesFor);
50
+ const ruleResult = report.rules[0];
51
+ const declaredNotRegistered = sortSites((ruleResult?.violations ?? [])
52
+ .filter((v) => v.direction === 'declared-missing')
53
+ .map((v) => ({ token: v.token, file: v.file, line: v.line })));
54
+ const registeredNotDeclared = sortSites((ruleResult?.violations ?? [])
55
+ .filter((v) => v.direction === 'registered-missing')
56
+ .map((v) => ({ token: v.token, file: v.file, line: v.line })));
57
+ return {
58
+ schema: WIRING_EXPLAIN_SCHEMA,
59
+ ruleId: rule.id,
60
+ ...(rule.description ? { description: rule.description } : {}),
61
+ mode: rule.mode === 'parity' ? 'parity' : 'subset',
62
+ ...(rule.groupBy ? { groupBy: rule.groupBy } : {}),
63
+ severity: rule.severity ?? 'error',
64
+ declared: {
65
+ sites: sortSites(declaredRes.sites),
66
+ distinctCount: ruleResult?.declaredCount ?? 0,
67
+ filesScanned: declaredFiles.length,
68
+ ...(declaredRes.error ? { error: declaredRes.error } : {}),
69
+ },
70
+ registered: {
71
+ sites: sortSites(registeredSites),
72
+ distinctCount: ruleResult?.registeredCount ?? 0,
73
+ filesScanned: registeredFiles.size,
74
+ ...(registeredError ? { error: registeredError } : {}),
75
+ },
76
+ declaredNotRegistered,
77
+ registeredNotDeclared,
78
+ verdict: report.verdict,
79
+ diagnostics: report.diagnostics,
80
+ };
81
+ }
@@ -0,0 +1,92 @@
1
+ import type { IRegistrationIdiom } from '@shrkcrft/core';
2
+ export declare const REGISTRATION_GRAPH_SCHEMA: "sharkcraft.registration-graph/v1";
3
+ /** A token role-site: which idiom + file:line it was found at. */
4
+ export interface IRegistrationSite {
5
+ readonly idiom: string;
6
+ readonly file: string;
7
+ readonly line: number;
8
+ }
9
+ /** One token in the registration graph and the three roles it plays. */
10
+ export interface IRegistrationNode {
11
+ readonly token: string;
12
+ /** Sites where the token is DECLARED (token/provider definition). */
13
+ readonly declared: readonly IRegistrationSite[];
14
+ /** Sites where the token is PROVIDED / REGISTERED into a composition. */
15
+ readonly provided: readonly IRegistrationSite[];
16
+ /** Sites where the token is CONSUMED / INJECTED. */
17
+ readonly consumed: readonly IRegistrationSite[];
18
+ }
19
+ /**
20
+ * The registration / DI graph: a peer to the import graph extracted from the
21
+ * declared idiom shapes, keyed by token. Distinct edge kinds (declared /
22
+ * provided / consumed) so the runtime-wiring questions imports can't answer
23
+ * become deterministic queries.
24
+ */
25
+ export interface IRegistrationGraph {
26
+ readonly schema: typeof REGISTRATION_GRAPH_SCHEMA;
27
+ /** Idiom names that contributed to this graph. */
28
+ readonly idioms: readonly string[];
29
+ /** Every distinct token, sorted, with its role-sites. */
30
+ readonly tokens: readonly IRegistrationNode[];
31
+ /** Misconfiguration messages (bad regex / no capture group / bad source). */
32
+ readonly diagnostics: readonly string[];
33
+ }
34
+ export interface IBuildRegistrationGraphOptions {
35
+ /** Project-relative directories to prune from the walk. */
36
+ readonly excludeDirs?: readonly string[];
37
+ }
38
+ /**
39
+ * Build the registration/DI graph from the declared idioms. For each idiom the
40
+ * three roles (declared / provided / consumed) are extracted with the shared
41
+ * alias-aware {@link collectSourceSites}, then bucketed by token across every
42
+ * idiom. Pure-engine output; the only IO is a single read-only tree walk over
43
+ * the union of all idiom globs. Never throws — a misconfigured source becomes a
44
+ * diagnostic.
45
+ */
46
+ export declare function buildRegistrationGraph(projectRoot: string, idioms: readonly IRegistrationIdiom[], options?: IBuildRegistrationGraphOptions): IRegistrationGraph;
47
+ /**
48
+ * A cheap content-signature of the exact files {@link buildRegistrationGraph}
49
+ * would read for these idioms: the sorted `relpath:mtimeMs:size` of every matched
50
+ * file, hashed. Because it reflects the graph's ACTUAL data source (a live tree
51
+ * walk) rather than the unrelated code-graph index, it is the correct key for any
52
+ * persisted registration-graph cache — a source edit shifts the signature even
53
+ * when no reindex has run, so a stale wiring verdict is impossible. Walk-and-stat
54
+ * only (no file reads), so it stays far cheaper than a full build. Never throws.
55
+ */
56
+ export declare function registrationGraphSignature(projectRoot: string, idioms: readonly IRegistrationIdiom[], options?: IBuildRegistrationGraphOptions): string;
57
+ /** A token's full registration chain, with role presence flags. */
58
+ export interface IRegistrationChain extends IRegistrationNode {
59
+ readonly isDeclared: boolean;
60
+ readonly isProvided: boolean;
61
+ readonly isConsumed: boolean;
62
+ }
63
+ /**
64
+ * `wiring chain <token>` — the declared → provided → consumed hops of one token
65
+ * with file:line at each, or `undefined` if the token is unknown to the graph.
66
+ */
67
+ export declare function registrationChain(graph: IRegistrationGraph, token: string): IRegistrationChain | undefined;
68
+ /** A token declared or injected but never provided — silently absent at runtime. */
69
+ export interface IUnprovidedToken {
70
+ readonly token: string;
71
+ readonly declared: readonly IRegistrationSite[];
72
+ readonly consumed: readonly IRegistrationSite[];
73
+ }
74
+ /**
75
+ * `wiring unprovided` — tokens that are DECLARED or CONSUMED but have ZERO
76
+ * provided sites. This is the silent-at-runtime class: typecheck/AOT-green, but
77
+ * the provider is never registered (or the injected token has no provider
78
+ * anywhere), so it resolves to undefined at runtime. The thing imports can't see.
79
+ */
80
+ export declare function registrationUnprovided(graph: IRegistrationGraph): readonly IUnprovidedToken[];
81
+ /** A token provided/registered that nothing consumes — a dead registration. */
82
+ export interface IOrphanRegistration {
83
+ readonly token: string;
84
+ readonly provided: readonly IRegistrationSite[];
85
+ }
86
+ /**
87
+ * `wiring orphans` — tokens that ARE provided/registered but have ZERO consumed
88
+ * sites: a provider/registration nothing injects (a build-clean no-op, or a
89
+ * sign the consumer was renamed/removed).
90
+ */
91
+ export declare function registrationOrphans(graph: IRegistrationGraph): readonly IOrphanRegistration[];
92
+ //# sourceMappingURL=registration-graph.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registration-graph.d.ts","sourceRoot":"","sources":["../../src/wiring/registration-graph.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAiB,MAAM,gBAAgB,CAAC;AAKxE,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;AAiBD;;;;;;;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,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;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,kBAAkB,GAAG,SAAS,gBAAgB,EAAE,CAI7F;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;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,kBAAkB,GAAG,SAAS,mBAAmB,EAAE,CAI7F"}
@@ -0,0 +1,124 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { statSync } from 'node:fs';
3
+ import * as nodePath from 'node:path';
4
+ import { matchesAny } from "../scan/glob.js";
5
+ import { readMatchingFiles, walkMatching } from "../util/walk-files.js";
6
+ import { collectSourceSites } from "./evaluate-wiring.js";
7
+ export const REGISTRATION_GRAPH_SCHEMA = 'sharkcraft.registration-graph/v1';
8
+ function sortSites(sites) {
9
+ return [...sites].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.idiom.localeCompare(b.idiom));
10
+ }
11
+ /** Union (deduped) of every glob any role of any idiom references. */
12
+ function registrationGlobs(idioms) {
13
+ return [
14
+ ...new Set(idioms.flatMap((i) => [...i.declared.files, ...i.provided.files, ...i.consumed.files])),
15
+ ];
16
+ }
17
+ /**
18
+ * Build the registration/DI graph from the declared idioms. For each idiom the
19
+ * three roles (declared / provided / consumed) are extracted with the shared
20
+ * alias-aware {@link collectSourceSites}, then bucketed by token across every
21
+ * idiom. Pure-engine output; the only IO is a single read-only tree walk over
22
+ * the union of all idiom globs. Never throws — a misconfigured source becomes a
23
+ * diagnostic.
24
+ */
25
+ export function buildRegistrationGraph(projectRoot, idioms, options = {}) {
26
+ const cache = readMatchingFiles(projectRoot, registrationGlobs(idioms), new Set(options.excludeDirs ?? []));
27
+ const entries = [...cache.entries()].map(([path, content]) => ({
28
+ path,
29
+ content,
30
+ }));
31
+ const filesFor = (source) => entries.filter((f) => matchesAny(f.path, source.files));
32
+ const declared = new Map();
33
+ const provided = new Map();
34
+ const consumed = new Map();
35
+ const diagnostics = [];
36
+ const harvest = (idiom, role, source, into) => {
37
+ const res = collectSourceSites(source, filesFor(source));
38
+ if (res.error)
39
+ diagnostics.push(`idiom "${idiom}" ${role}: ${res.error}`);
40
+ for (const s of res.sites) {
41
+ const list = into.get(s.token) ?? [];
42
+ list.push({ idiom, file: s.file, line: s.line });
43
+ into.set(s.token, list);
44
+ }
45
+ };
46
+ for (const idiom of idioms) {
47
+ harvest(idiom.name, 'declared', idiom.declared, declared);
48
+ harvest(idiom.name, 'provided', idiom.provided, provided);
49
+ harvest(idiom.name, 'consumed', idiom.consumed, consumed);
50
+ }
51
+ const tokenSet = new Set([...declared.keys(), ...provided.keys(), ...consumed.keys()]);
52
+ const tokens = [...tokenSet].sort().map((token) => ({
53
+ token,
54
+ declared: sortSites(declared.get(token) ?? []),
55
+ provided: sortSites(provided.get(token) ?? []),
56
+ consumed: sortSites(consumed.get(token) ?? []),
57
+ }));
58
+ return {
59
+ schema: REGISTRATION_GRAPH_SCHEMA,
60
+ idioms: idioms.map((i) => i.name),
61
+ tokens,
62
+ diagnostics,
63
+ };
64
+ }
65
+ /**
66
+ * A cheap content-signature of the exact files {@link buildRegistrationGraph}
67
+ * would read for these idioms: the sorted `relpath:mtimeMs:size` of every matched
68
+ * file, hashed. Because it reflects the graph's ACTUAL data source (a live tree
69
+ * walk) rather than the unrelated code-graph index, it is the correct key for any
70
+ * persisted registration-graph cache — a source edit shifts the signature even
71
+ * when no reindex has run, so a stale wiring verdict is impossible. Walk-and-stat
72
+ * only (no file reads), so it stays far cheaper than a full build. Never throws.
73
+ */
74
+ export function registrationGraphSignature(projectRoot, idioms, options = {}) {
75
+ const files = walkMatching(projectRoot, registrationGlobs(idioms), new Set(options.excludeDirs ?? [])).sort();
76
+ const parts = [];
77
+ for (const rel of files) {
78
+ try {
79
+ const st = statSync(nodePath.join(projectRoot, rel));
80
+ parts.push(`${rel}:${st.mtimeMs}:${st.size}`);
81
+ }
82
+ catch {
83
+ // Unreadable / racing delete — omit; a real content change still shifts
84
+ // the hash via the surviving entries (and via this file's disappearance).
85
+ }
86
+ }
87
+ return createHash('sha1').update(parts.join('\n')).digest('hex').slice(0, 16);
88
+ }
89
+ /**
90
+ * `wiring chain <token>` — the declared → provided → consumed hops of one token
91
+ * with file:line at each, or `undefined` if the token is unknown to the graph.
92
+ */
93
+ export function registrationChain(graph, token) {
94
+ const node = graph.tokens.find((t) => t.token === token);
95
+ if (!node)
96
+ return undefined;
97
+ return {
98
+ ...node,
99
+ isDeclared: node.declared.length > 0,
100
+ isProvided: node.provided.length > 0,
101
+ isConsumed: node.consumed.length > 0,
102
+ };
103
+ }
104
+ /**
105
+ * `wiring unprovided` — tokens that are DECLARED or CONSUMED but have ZERO
106
+ * provided sites. This is the silent-at-runtime class: typecheck/AOT-green, but
107
+ * the provider is never registered (or the injected token has no provider
108
+ * anywhere), so it resolves to undefined at runtime. The thing imports can't see.
109
+ */
110
+ export function registrationUnprovided(graph) {
111
+ return graph.tokens
112
+ .filter((t) => t.provided.length === 0 && (t.declared.length > 0 || t.consumed.length > 0))
113
+ .map((t) => ({ token: t.token, declared: t.declared, consumed: t.consumed }));
114
+ }
115
+ /**
116
+ * `wiring orphans` — tokens that ARE provided/registered but have ZERO consumed
117
+ * sites: a provider/registration nothing injects (a build-clean no-op, or a
118
+ * sign the consumer was renamed/removed).
119
+ */
120
+ export function registrationOrphans(graph) {
121
+ return graph.tokens
122
+ .filter((t) => t.provided.length > 0 && t.consumed.length === 0)
123
+ .map((t) => ({ token: t.token, provided: t.provided }));
124
+ }
@@ -0,0 +1,60 @@
1
+ export declare const TRACE_SCHEMA: "sharkcraft.trace/v1";
2
+ /** Default source globs scanned when `trace` is given no `--glob`. */
3
+ export declare const TRACE_DEFAULT_GLOBS: readonly string[];
4
+ /**
5
+ * How a literal occurrence relates to the cross-fence contract — the direction
6
+ * grep can't give you:
7
+ * - `declare` — a canonical definition (`const X = 'lit'`, `kind: 'lit'`, an enum value).
8
+ * - `register` — added to a collection / mapping (`register('lit')`, an array element, `{ 'lit': … }`).
9
+ * - `consume` — compared / switched / handled (`=== 'lit'`, `case 'lit':`).
10
+ * - `reference`— an occurrence we can't confidently classify (still reported, with context).
11
+ */
12
+ export declare enum TraceRole {
13
+ Declare = "declare",
14
+ Register = "register",
15
+ Consume = "consume",
16
+ Reference = "reference"
17
+ }
18
+ /** One occurrence of the traced literal (or a const aliased to it). */
19
+ export interface ITraceSite {
20
+ readonly file: string;
21
+ readonly line: number;
22
+ readonly role: TraceRole;
23
+ /** The trimmed source line, for context. */
24
+ readonly text: string;
25
+ /** Set when this site reached the literal through a `const NAME = 'literal'` alias. */
26
+ readonly viaAlias?: string;
27
+ }
28
+ export interface ITraceReport {
29
+ readonly schema: typeof TRACE_SCHEMA;
30
+ readonly literal: string;
31
+ readonly total: number;
32
+ /** Distinct files the literal (or an alias) was found in. */
33
+ readonly files: number;
34
+ /** Sites grouped by role, each group sorted by (file, line). */
35
+ readonly byRole: Readonly<Record<TraceRole, readonly ITraceSite[]>>;
36
+ /** Const names found bound to the literal (`const NAME = 'literal'`), if any. */
37
+ readonly aliases: readonly string[];
38
+ }
39
+ export interface ITraceOptions {
40
+ /** Override the default source globs. */
41
+ readonly globs?: readonly string[];
42
+ /** Project-relative directories to prune from the walk. */
43
+ readonly excludeDirs?: readonly string[];
44
+ /**
45
+ * Resolve `const NAME = 'literal'` bindings and also classify uses of NAME
46
+ * (flagged `viaAlias`). On by default — it is the cross-fence value over grep.
47
+ */
48
+ readonly resolveAliases?: boolean;
49
+ }
50
+ /**
51
+ * Trace every declare → register → consume site of an EXACT string literal
52
+ * across the tree, classifying each occurrence with a direction. Generalizes
53
+ * `registry where` to any cross-fence string contract (a kind slug, permission
54
+ * id, route key, data key) WITHOUT a pre-declared registry — point it at any
55
+ * literal and get the chain grep can't: direction + role + layer-spanning
56
+ * grouping, plus (by default) the const-alias bindings of the literal and their
57
+ * use-sites. Pure-engine; the only IO is one read-only tree walk. Never throws.
58
+ */
59
+ export declare function traceLiteral(projectRoot: string, literal: string, options?: ITraceOptions): ITraceReport;
60
+ //# sourceMappingURL=trace-literal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trace-literal.d.ts","sourceRoot":"","sources":["../../src/wiring/trace-literal.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,EAAG,qBAA8B,CAAC;AAE3D,sEAAsE;AACtE,eAAO,MAAM,mBAAmB,EAAE,SAAS,MAAM,EAShD,CAAC;AAEF;;;;;;;GAOG;AACH,oBAAY,SAAS;IACnB,OAAO,YAAY;IACnB,QAAQ,aAAa;IACrB,OAAO,YAAY;IACnB,SAAS,cAAc;CACxB;AAED,uEAAuE;AACvE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,4CAA4C;IAC5C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,OAAO,YAAY,CAAC;IACrC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,gEAAgE;IAChE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,EAAE,SAAS,UAAU,EAAE,CAAC,CAAC,CAAC;IACpE,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,MAAM,WAAW,aAAa;IAC5B,yCAAyC;IACzC,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;CACnC;AAmED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAC1B,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,aAAkB,GAC1B,YAAY,CAqFd"}
@@ -0,0 +1,187 @@
1
+ import { readMatchingFiles } from "../util/walk-files.js";
2
+ export const TRACE_SCHEMA = 'sharkcraft.trace/v1';
3
+ /** Default source globs scanned when `trace` is given no `--glob`. */
4
+ export const TRACE_DEFAULT_GLOBS = [
5
+ '**/*.ts',
6
+ '**/*.tsx',
7
+ '**/*.mts',
8
+ '**/*.cts',
9
+ '**/*.js',
10
+ '**/*.jsx',
11
+ '**/*.mjs',
12
+ '**/*.cjs',
13
+ ];
14
+ /**
15
+ * How a literal occurrence relates to the cross-fence contract — the direction
16
+ * grep can't give you:
17
+ * - `declare` — a canonical definition (`const X = 'lit'`, `kind: 'lit'`, an enum value).
18
+ * - `register` — added to a collection / mapping (`register('lit')`, an array element, `{ 'lit': … }`).
19
+ * - `consume` — compared / switched / handled (`=== 'lit'`, `case 'lit':`).
20
+ * - `reference`— an occurrence we can't confidently classify (still reported, with context).
21
+ */
22
+ export var TraceRole;
23
+ (function (TraceRole) {
24
+ TraceRole["Declare"] = "declare";
25
+ TraceRole["Register"] = "register";
26
+ TraceRole["Consume"] = "consume";
27
+ TraceRole["Reference"] = "reference";
28
+ })(TraceRole || (TraceRole = {}));
29
+ function escapeRegex(s) {
30
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ }
32
+ function lineStarts(content) {
33
+ const starts = [0];
34
+ for (let i = 0; i < content.length; i += 1) {
35
+ if (content[i] === '\n')
36
+ starts.push(i + 1);
37
+ }
38
+ return starts;
39
+ }
40
+ /** 1-based line for a character offset, given precomputed line-start offsets. */
41
+ function lineAt(starts, offset) {
42
+ // Binary search for the greatest start <= offset.
43
+ let lo = 0;
44
+ let hi = starts.length - 1;
45
+ while (lo < hi) {
46
+ const mid = (lo + hi + 1) >> 1;
47
+ if (starts[mid] <= offset)
48
+ lo = mid;
49
+ else
50
+ hi = mid - 1;
51
+ }
52
+ return lo + 1;
53
+ }
54
+ function lineText(content, starts, line) {
55
+ const start = starts[line - 1] ?? 0;
56
+ const nl = content.indexOf('\n', start);
57
+ return content.slice(start, nl === -1 ? content.length : nl);
58
+ }
59
+ /**
60
+ * Heuristic declare-vs-register-vs-consume classifier from the text immediately
61
+ * around the occurrence. High-precision rules first; anything unmatched stays
62
+ * `reference` (reported, never silently dropped). Deterministic.
63
+ */
64
+ function classifyRole(before, after) {
65
+ const b = before.replace(/\s+$/, '');
66
+ const a = after.replace(/^\s+/, '');
67
+ // switch/case handler.
68
+ if (/\bcase$/.test(b) && a.startsWith(':'))
69
+ return TraceRole.Consume;
70
+ // equality / inequality comparison on either side.
71
+ if (/(===|!==|==|!=)$/.test(b) || /^(===|!==|==|!=)/.test(a))
72
+ return TraceRole.Consume;
73
+ // object / map key mapping: `{ 'lit': … }` or `, 'lit': …`.
74
+ if (a.startsWith(':') && /[{,]$/.test(b))
75
+ return TraceRole.Register;
76
+ // registration-ish call: register('lit'), provide('lit'), on('lit'), .set('lit', …).
77
+ if (/\b(register|provide|add|use|on|handle|bind|emit|dispatch|listen|subscribe|define)\w*\($/i.test(b)) {
78
+ return TraceRole.Register;
79
+ }
80
+ if (/\.\s*set\($/.test(b))
81
+ return TraceRole.Register;
82
+ // array element: the literal directly follows `[` or `,`.
83
+ if (/[[,]$/.test(b))
84
+ return TraceRole.Register;
85
+ // canonical binding: const/let/var X = 'lit'.
86
+ if (/\b(?:const|let|var)\s+[\w$]+\s*=$/.test(b))
87
+ return TraceRole.Declare;
88
+ // definition-ish object property: `kind: 'lit'`, `id = 'lit'`, etc.
89
+ if (/\b(kind|type|id|name|slug|key|tag|code|token|permission|route|event|action|channel|topic|status|provide|providerToken)\s*[:=]$/i.test(b)) {
90
+ return TraceRole.Declare;
91
+ }
92
+ return TraceRole.Reference;
93
+ }
94
+ /** A const name is alias-resolvable only if Pascal/SCREAMING-cased (low collision risk). */
95
+ const ALIAS_NAME = /^[A-Z][\w$]*$/;
96
+ /**
97
+ * Trace every declare → register → consume site of an EXACT string literal
98
+ * across the tree, classifying each occurrence with a direction. Generalizes
99
+ * `registry where` to any cross-fence string contract (a kind slug, permission
100
+ * id, route key, data key) WITHOUT a pre-declared registry — point it at any
101
+ * literal and get the chain grep can't: direction + role + layer-spanning
102
+ * grouping, plus (by default) the const-alias bindings of the literal and their
103
+ * use-sites. Pure-engine; the only IO is one read-only tree walk. Never throws.
104
+ */
105
+ export function traceLiteral(projectRoot, literal, options = {}) {
106
+ const globs = options.globs && options.globs.length > 0 ? options.globs : TRACE_DEFAULT_GLOBS;
107
+ const exclude = new Set(options.excludeDirs ?? []);
108
+ const cache = readMatchingFiles(projectRoot, globs, exclude);
109
+ const sites = [];
110
+ const aliasNames = new Set();
111
+ // Exact quoted-literal match: the closing quote immediately follows, so the
112
+ // quoted CONTENT equals the literal (not a substring of a longer string).
113
+ const litRe = new RegExp(`(['"\`])${escapeRegex(literal)}\\1`, 'g');
114
+ for (const [file, content] of cache) {
115
+ const starts = lineStarts(content);
116
+ litRe.lastIndex = 0;
117
+ let m;
118
+ while ((m = litRe.exec(content)) !== null) {
119
+ if (m.index === litRe.lastIndex)
120
+ litRe.lastIndex += 1;
121
+ const line = lineAt(starts, m.index);
122
+ const text = lineText(content, starts, line);
123
+ const col = m.index - (starts[line - 1] ?? 0);
124
+ const before = text.slice(0, col);
125
+ const after = text.slice(col + m[0].length);
126
+ const role = classifyRole(before, after);
127
+ sites.push({ file, line, role, text: text.trim() });
128
+ // Capture a const alias bound to this literal for the second pass.
129
+ const bind = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=$/.exec(before.replace(/\s+$/, ''));
130
+ if (bind && ALIAS_NAME.test(bind[1]))
131
+ aliasNames.add(bind[1]);
132
+ }
133
+ }
134
+ // Second pass: classify uses of any const aliased to the literal.
135
+ const resolveAliases = options.resolveAliases !== false;
136
+ if (resolveAliases && aliasNames.size > 0) {
137
+ for (const name of aliasNames) {
138
+ // The lookbehind excludes `.` as well as word chars/`$`: a genuine use of
139
+ // the top-level const alias is never dot-prefixed, whereas `Enum.NAME` /
140
+ // `obj.NAME` is a member/enum accessor that merely shares the alias's name
141
+ // and must NOT be counted as a use of the traced literal (over-match).
142
+ const useRe = new RegExp(`(?<![\\w$.])${escapeRegex(name)}(?![\\w$])`, 'g');
143
+ for (const [file, content] of cache) {
144
+ const starts = lineStarts(content);
145
+ useRe.lastIndex = 0;
146
+ let m;
147
+ while ((m = useRe.exec(content)) !== null) {
148
+ const line = lineAt(starts, m.index);
149
+ const text = lineText(content, starts, line);
150
+ const col = m.index - (starts[line - 1] ?? 0);
151
+ const before = text.slice(0, col);
152
+ const after = text.slice(col + name.length);
153
+ // Skip ONLY the binding occurrence itself (`const NAME = …`) — it's
154
+ // already counted as a literal Declare site. Keying on the exact
155
+ // `const NAME` position (not the whole line) means a genuine alias use
156
+ // that merely SHARES a line with some literal is still reported.
157
+ if (/\b(?:const|let|var)\s+$/.test(before))
158
+ continue;
159
+ const role = classifyRole(before, after);
160
+ sites.push({ file, line, role, text: text.trim(), viaAlias: name });
161
+ }
162
+ }
163
+ }
164
+ }
165
+ const sortSites = (xs) => [...xs].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
166
+ const byRole = {
167
+ [TraceRole.Declare]: [],
168
+ [TraceRole.Register]: [],
169
+ [TraceRole.Consume]: [],
170
+ [TraceRole.Reference]: [],
171
+ };
172
+ for (const s of sites)
173
+ byRole[s.role].push(s);
174
+ return {
175
+ schema: TRACE_SCHEMA,
176
+ literal,
177
+ total: sites.length,
178
+ files: new Set(sites.map((s) => s.file)).size,
179
+ byRole: {
180
+ [TraceRole.Declare]: sortSites(byRole[TraceRole.Declare]),
181
+ [TraceRole.Register]: sortSites(byRole[TraceRole.Register]),
182
+ [TraceRole.Consume]: sortSites(byRole[TraceRole.Consume]),
183
+ [TraceRole.Reference]: sortSites(byRole[TraceRole.Reference]),
184
+ },
185
+ aliases: [...aliasNames].sort(),
186
+ };
187
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shrkcrft/boundaries",
3
- "version": "0.1.0-alpha.23",
3
+ "version": "0.1.0-alpha.24",
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.23"
46
+ "@shrkcrft/core": "^0.1.0-alpha.24"
47
47
  },
48
48
  "publishConfig": {
49
49
  "access": "public"