@shrkcrft/api-surface-diff 0.1.0-alpha.10
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/cache/signature-cache.d.ts +34 -0
- package/dist/cache/signature-cache.d.ts.map +1 -0
- package/dist/cache/signature-cache.js +47 -0
- package/dist/engine/diff-surfaces.d.ts +18 -0
- package/dist/engine/diff-surfaces.d.ts.map +1 -0
- package/dist/engine/diff-surfaces.js +150 -0
- package/dist/engine/extract-surface-with-program.d.ts +52 -0
- package/dist/engine/extract-surface-with-program.d.ts.map +1 -0
- package/dist/engine/extract-surface-with-program.js +456 -0
- package/dist/engine/extract-surface.d.ts +18 -0
- package/dist/engine/extract-surface.d.ts.map +1 -0
- package/dist/engine/extract-surface.js +65 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/schema/api-surface.d.ts +74 -0
- package/dist/schema/api-surface.d.ts.map +1 -0
- package/dist/schema/api-surface.js +7 -0
- package/package.json +53 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const SIGNATURE_CACHE_SCHEMA: "sharkcraft.api-surface-cache/v1";
|
|
2
|
+
/**
|
|
3
|
+
* Per-file entry. `sha1` is the SHA1 of the source file's contents at
|
|
4
|
+
* the time the cache was written. `signatures` maps `symbol-name +
|
|
5
|
+
* isDefault` (within that file) to its canonical signature string.
|
|
6
|
+
*
|
|
7
|
+
* We key signatures within a file by `${name}|${isDefault?1:0}` rather
|
|
8
|
+
* than by global symbol id so the cache survives file renames (a file
|
|
9
|
+
* rename invalidates the SHA1 anyway, so the cache misses on the old
|
|
10
|
+
* file but the new file builds fresh).
|
|
11
|
+
*/
|
|
12
|
+
export interface ISignatureCacheFileEntry {
|
|
13
|
+
/** SHA1 of file contents when the cache was written. */
|
|
14
|
+
sha1: string;
|
|
15
|
+
/** keyed by `${name}|${isDefault?1:0}`. */
|
|
16
|
+
signatures: Readonly<Record<string, string>>;
|
|
17
|
+
}
|
|
18
|
+
export interface ISignatureCache {
|
|
19
|
+
schema: typeof SIGNATURE_CACHE_SCHEMA;
|
|
20
|
+
generatedAt: string;
|
|
21
|
+
/** Workspace-relative POSIX paths → per-file cache entry. */
|
|
22
|
+
files: Readonly<Record<string, ISignatureCacheFileEntry>>;
|
|
23
|
+
}
|
|
24
|
+
export declare function emptyCache(): ISignatureCache;
|
|
25
|
+
export declare function loadSignatureCache(projectRoot: string): ISignatureCache;
|
|
26
|
+
export declare function saveSignatureCache(projectRoot: string, cache: ISignatureCache): void;
|
|
27
|
+
export declare function symbolCacheKey(name: string, isDefault: boolean): string;
|
|
28
|
+
/**
|
|
29
|
+
* Lightweight content fingerprint. Used to invalidate cached
|
|
30
|
+
* signatures when a source file changes. Mirrors `@shrkcrft/graph`'s
|
|
31
|
+
* fingerprint algorithm so the two stores agree on file identity.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fingerprintContent(text: string): string;
|
|
34
|
+
//# sourceMappingURL=signature-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signature-cache.d.ts","sourceRoot":"","sources":["../../src/cache/signature-cache.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,EAAG,iCAA0C,CAAC;AAGjF;;;;;;;;;GASG;AACH,MAAM,WAAW,wBAAwB;IACvC,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC9C;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,OAAO,sBAAsB,CAAC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC,CAAC;CAC3D;AAED,wBAAgB,UAAU,IAAI,eAAe,CAM5C;AAED,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,eAAe,CAWvE;AAED,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,IAAI,CAQpF;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CAEvE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvD"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import * as nodePath from 'node:path';
|
|
4
|
+
export const SIGNATURE_CACHE_SCHEMA = 'sharkcraft.api-surface-cache/v1';
|
|
5
|
+
const CACHE_REL = '.sharkcraft/api-surface/signatures.json';
|
|
6
|
+
export function emptyCache() {
|
|
7
|
+
return {
|
|
8
|
+
schema: SIGNATURE_CACHE_SCHEMA,
|
|
9
|
+
generatedAt: new Date().toISOString(),
|
|
10
|
+
files: {},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function loadSignatureCache(projectRoot) {
|
|
14
|
+
const abs = nodePath.join(projectRoot, CACHE_REL);
|
|
15
|
+
if (!existsSync(abs))
|
|
16
|
+
return emptyCache();
|
|
17
|
+
try {
|
|
18
|
+
const raw = JSON.parse(readFileSync(abs, 'utf8'));
|
|
19
|
+
if (raw.schema !== SIGNATURE_CACHE_SCHEMA)
|
|
20
|
+
return emptyCache();
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Corrupted cache — treat as cold start. The next write will overwrite.
|
|
25
|
+
return emptyCache();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function saveSignatureCache(projectRoot, cache) {
|
|
29
|
+
const abs = nodePath.join(projectRoot, CACHE_REL);
|
|
30
|
+
mkdirSync(nodePath.dirname(abs), { recursive: true });
|
|
31
|
+
const stamped = {
|
|
32
|
+
...cache,
|
|
33
|
+
generatedAt: new Date().toISOString(),
|
|
34
|
+
};
|
|
35
|
+
writeFileSync(abs, JSON.stringify(stamped, null, 2), 'utf8');
|
|
36
|
+
}
|
|
37
|
+
export function symbolCacheKey(name, isDefault) {
|
|
38
|
+
return `${name}|${isDefault ? 1 : 0}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Lightweight content fingerprint. Used to invalidate cached
|
|
42
|
+
* signatures when a source file changes. Mirrors `@shrkcrft/graph`'s
|
|
43
|
+
* fingerprint algorithm so the two stores agree on file identity.
|
|
44
|
+
*/
|
|
45
|
+
export function fingerprintContent(text) {
|
|
46
|
+
return createHash('sha1').update(text).digest('hex');
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type DiffChangeKind, type IApiSurface, type IApiSurfaceDiff } from '../schema/api-surface.js';
|
|
2
|
+
/**
|
|
3
|
+
* Compare two API surface snapshots and return a structured diff.
|
|
4
|
+
*
|
|
5
|
+
* Matching strategy: symbols are paired by `(package, name, isDefault)`
|
|
6
|
+
* tuple. This means a symbol that moved files (within the same
|
|
7
|
+
* package) is reported as `moved-file` (additive); a symbol that moved
|
|
8
|
+
* packages is `moved-package` (breaking — consumers' imports must
|
|
9
|
+
* change). Pure rename or kind change with the same matching tuple is
|
|
10
|
+
* reported as `kind-changed`.
|
|
11
|
+
*
|
|
12
|
+
* `removed` entries are always **breaking**. `added` entries are
|
|
13
|
+
* **additive**. Returned `entries` are sorted breaking → additive →
|
|
14
|
+
* info, then alphabetically by symbol name.
|
|
15
|
+
*/
|
|
16
|
+
export declare function diffApiSurfaces(baseline: IApiSurface, current: IApiSurface): IApiSurfaceDiff;
|
|
17
|
+
export type { DiffChangeKind };
|
|
18
|
+
//# sourceMappingURL=diff-surfaces.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diff-surfaces.d.ts","sourceRoot":"","sources":["../../src/engine/diff-surfaces.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,eAAe,EAGrB,MAAM,0BAA0B,CAAC;AAElC;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,GAAG,eAAe,CAuH5F;AAqBD,YAAY,EAAE,cAAc,EAAE,CAAC"}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { API_SURFACE_DIFF_SCHEMA, } from "../schema/api-surface.js";
|
|
2
|
+
/**
|
|
3
|
+
* Compare two API surface snapshots and return a structured diff.
|
|
4
|
+
*
|
|
5
|
+
* Matching strategy: symbols are paired by `(package, name, isDefault)`
|
|
6
|
+
* tuple. This means a symbol that moved files (within the same
|
|
7
|
+
* package) is reported as `moved-file` (additive); a symbol that moved
|
|
8
|
+
* packages is `moved-package` (breaking — consumers' imports must
|
|
9
|
+
* change). Pure rename or kind change with the same matching tuple is
|
|
10
|
+
* reported as `kind-changed`.
|
|
11
|
+
*
|
|
12
|
+
* `removed` entries are always **breaking**. `added` entries are
|
|
13
|
+
* **additive**. Returned `entries` are sorted breaking → additive →
|
|
14
|
+
* info, then alphabetically by symbol name.
|
|
15
|
+
*/
|
|
16
|
+
export function diffApiSurfaces(baseline, current) {
|
|
17
|
+
const baselineByKey = new Map();
|
|
18
|
+
const currentByKey = new Map();
|
|
19
|
+
for (const s of baseline.symbols)
|
|
20
|
+
baselineByKey.set(keyOf(s), s);
|
|
21
|
+
for (const s of current.symbols)
|
|
22
|
+
currentByKey.set(keyOf(s), s);
|
|
23
|
+
const entries = [];
|
|
24
|
+
// Removed: present in baseline, absent in current.
|
|
25
|
+
for (const [key, b] of baselineByKey) {
|
|
26
|
+
if (currentByKey.has(key))
|
|
27
|
+
continue;
|
|
28
|
+
entries.push({
|
|
29
|
+
kind: 'removed',
|
|
30
|
+
severity: 'breaking',
|
|
31
|
+
message: `removed: ${describe(b)}`,
|
|
32
|
+
symbol: b,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// Added or modified.
|
|
36
|
+
for (const [key, c] of currentByKey) {
|
|
37
|
+
const b = baselineByKey.get(key);
|
|
38
|
+
if (!b) {
|
|
39
|
+
entries.push({
|
|
40
|
+
kind: 'added',
|
|
41
|
+
severity: 'additive',
|
|
42
|
+
message: `added: ${describe(c)}`,
|
|
43
|
+
symbol: c,
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Kind change.
|
|
48
|
+
if (b.kind !== c.kind) {
|
|
49
|
+
entries.push({
|
|
50
|
+
kind: 'kind-changed',
|
|
51
|
+
severity: kindChangeSeverity(b.kind, c.kind),
|
|
52
|
+
message: `kind change: ${describe(c)} was ${b.kind}, now ${c.kind}`,
|
|
53
|
+
symbol: c,
|
|
54
|
+
previous: b,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// Signature change (only when both surfaces carry signatures —
|
|
58
|
+
// typically when extracted via ts.Program with --with-signatures).
|
|
59
|
+
if (b.signature && c.signature && b.signature !== c.signature) {
|
|
60
|
+
entries.push({
|
|
61
|
+
kind: 'signature-changed',
|
|
62
|
+
severity: 'breaking',
|
|
63
|
+
message: `signature change: ${describe(c)}\n was: ${b.signature}\n now: ${c.signature}`,
|
|
64
|
+
symbol: c,
|
|
65
|
+
previous: b,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// File move within the same package (additive).
|
|
69
|
+
if (b.file !== c.file) {
|
|
70
|
+
entries.push({
|
|
71
|
+
kind: 'moved-file',
|
|
72
|
+
severity: 'additive',
|
|
73
|
+
message: `moved: ${describe(c)} from ${b.file} → ${c.file}`,
|
|
74
|
+
symbol: c,
|
|
75
|
+
previous: b,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Cross-package moves (breaking): same name, different package.
|
|
80
|
+
// These appear as `removed` + `added` from the key-based match above.
|
|
81
|
+
// Reclassify them.
|
|
82
|
+
const byNameRemoved = new Map();
|
|
83
|
+
const byNameAdded = new Map();
|
|
84
|
+
for (const e of entries) {
|
|
85
|
+
if (e.kind === 'removed')
|
|
86
|
+
byNameRemoved.set(e.symbol.name, e.symbol);
|
|
87
|
+
if (e.kind === 'added')
|
|
88
|
+
byNameAdded.set(e.symbol.name, e.symbol);
|
|
89
|
+
}
|
|
90
|
+
const reclassified = [];
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
if (e.kind === 'removed') {
|
|
93
|
+
const counterpart = byNameAdded.get(e.symbol.name);
|
|
94
|
+
if (counterpart && counterpart.package !== e.symbol.package) {
|
|
95
|
+
reclassified.push({
|
|
96
|
+
kind: 'moved-package',
|
|
97
|
+
severity: 'breaking',
|
|
98
|
+
message: `moved package: ${e.symbol.name} from ${e.symbol.package ?? '?'} → ${counterpart.package ?? '?'}`,
|
|
99
|
+
symbol: counterpart,
|
|
100
|
+
previous: e.symbol,
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else if (e.kind === 'added') {
|
|
106
|
+
const counterpart = byNameRemoved.get(e.symbol.name);
|
|
107
|
+
if (counterpart && counterpart.package !== e.symbol.package) {
|
|
108
|
+
// Skip — already represented as moved-package via the removed side.
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
reclassified.push(e);
|
|
113
|
+
}
|
|
114
|
+
// Sort: breaking first, then additive, then info; tiebreak by name.
|
|
115
|
+
const severityRank = { breaking: 0, additive: 1, info: 2 };
|
|
116
|
+
reclassified.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] ||
|
|
117
|
+
a.symbol.name.localeCompare(b.symbol.name));
|
|
118
|
+
const added = reclassified.filter((e) => e.kind === 'added').length;
|
|
119
|
+
const removed = reclassified.filter((e) => e.kind === 'removed').length;
|
|
120
|
+
const changed = reclassified.length - added - removed;
|
|
121
|
+
const breakingCount = reclassified.filter((e) => e.severity === 'breaking').length;
|
|
122
|
+
return {
|
|
123
|
+
schema: API_SURFACE_DIFF_SCHEMA,
|
|
124
|
+
baselineTotal: baseline.total,
|
|
125
|
+
currentTotal: current.total,
|
|
126
|
+
added,
|
|
127
|
+
removed,
|
|
128
|
+
changed,
|
|
129
|
+
breakingCount,
|
|
130
|
+
entries: reclassified,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function keyOf(s) {
|
|
134
|
+
return `${s.package ?? ''}|${s.name}|${s.isDefault ? '1' : '0'}`;
|
|
135
|
+
}
|
|
136
|
+
function describe(s) {
|
|
137
|
+
return s.package ? `${s.package}#${s.name} (${s.kind})` : `${s.name} (${s.kind})`;
|
|
138
|
+
}
|
|
139
|
+
function kindChangeSeverity(from, to) {
|
|
140
|
+
// class ↔ function ↔ const are likely breaking; interface ↔ type-alias
|
|
141
|
+
// is usually additive (TS treats them interchangeably for most uses).
|
|
142
|
+
if (from === 'interface' && to === 'type-alias')
|
|
143
|
+
return 'additive';
|
|
144
|
+
if (from === 'type-alias' && to === 'interface')
|
|
145
|
+
return 'additive';
|
|
146
|
+
// Re-export wrappers can change kind details for cosmetic reasons.
|
|
147
|
+
if (from === 'unknown' || to === 'unknown')
|
|
148
|
+
return 'info';
|
|
149
|
+
return 'breaking';
|
|
150
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type IApiSurface } from '../schema/api-surface.js';
|
|
2
|
+
export interface IExtractWithProgramOptions {
|
|
3
|
+
projectRoot: string;
|
|
4
|
+
/** Restrict to these workspace packages. */
|
|
5
|
+
packageFilter?: readonly string[];
|
|
6
|
+
/** Path to tsconfig (default: `tsconfig.base.json` then `tsconfig.json`). */
|
|
7
|
+
tsconfigPath?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Cap on the wall-clock time the extractor will spend (ms). When
|
|
10
|
+
* exceeded, the extractor returns what it has so far + a diagnostic.
|
|
11
|
+
* Default 60 s.
|
|
12
|
+
*/
|
|
13
|
+
timeBudgetMs?: number;
|
|
14
|
+
/**
|
|
15
|
+
* When true (default), reuse `.sharkcraft/api-surface/signatures.json`
|
|
16
|
+
* entries whose file SHA1 matches the current content. Saves a few
|
|
17
|
+
* hundred ms on incremental CI runs. Pass `false` to force a
|
|
18
|
+
* full rebuild (the cache is overwritten on the next save).
|
|
19
|
+
*/
|
|
20
|
+
useCache?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface IExtractWithProgramResult {
|
|
23
|
+
surface: IApiSurface;
|
|
24
|
+
diagnostics: readonly string[];
|
|
25
|
+
/** Number of source files visited by the type checker. */
|
|
26
|
+
filesVisited: number;
|
|
27
|
+
/** Wall-clock duration. */
|
|
28
|
+
durationMs: number;
|
|
29
|
+
/** Signature cache hit / miss / unchanged-file counts. */
|
|
30
|
+
cacheStats: {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
hits: number;
|
|
33
|
+
misses: number;
|
|
34
|
+
/** Files whose every exported symbol was a cache hit. */
|
|
35
|
+
filesReused: number;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build a `ts.Program` for the project and harvest a public-API surface
|
|
40
|
+
* with **canonical signature strings** for each exported symbol. The
|
|
41
|
+
* signatures let the diff engine catch parameter-type / return-type /
|
|
42
|
+
* member-type changes that the AST-only extractor misses.
|
|
43
|
+
*
|
|
44
|
+
* Costs:
|
|
45
|
+
* - ts.Program over a medium-sized repo: hundreds of ms to a few s.
|
|
46
|
+
* - Memory: roughly proportional to total .ts size.
|
|
47
|
+
*
|
|
48
|
+
* Opt-in only — the AST-only `extractApiSurface(snap)` remains the
|
|
49
|
+
* default path. This extractor is what `--with-signatures` invokes.
|
|
50
|
+
*/
|
|
51
|
+
export declare function extractApiSurfaceWithProgram(options: IExtractWithProgramOptions): IExtractWithProgramResult;
|
|
52
|
+
//# sourceMappingURL=extract-surface-with-program.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extract-surface-with-program.d.ts","sourceRoot":"","sources":["../../src/engine/extract-surface-with-program.ts"],"names":[],"mappings":"AAYA,OAAO,EAGL,KAAK,WAAW,EAEjB,MAAM,0BAA0B,CAAC;AAElC,MAAM,WAAW,0BAA0B;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,yBAAyB;IACxC,OAAO,EAAE,WAAW,CAAC;IACrB,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,0DAA0D;IAC1D,YAAY,EAAE,MAAM,CAAC;IACrB,2BAA2B;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,UAAU,EAAE;QACV,OAAO,EAAE,OAAO,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC;QACf,yDAAyD;QACzD,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;CACH;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,0BAA0B,GAClC,yBAAyB,CA8J3B"}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import * as nodePath from 'node:path';
|
|
3
|
+
import * as ts from 'typescript';
|
|
4
|
+
import { emptyCache, fingerprintContent, loadSignatureCache, saveSignatureCache, symbolCacheKey, } from "../cache/signature-cache.js";
|
|
5
|
+
import { API_SURFACE_SCHEMA, } from "../schema/api-surface.js";
|
|
6
|
+
/**
|
|
7
|
+
* Build a `ts.Program` for the project and harvest a public-API surface
|
|
8
|
+
* with **canonical signature strings** for each exported symbol. The
|
|
9
|
+
* signatures let the diff engine catch parameter-type / return-type /
|
|
10
|
+
* member-type changes that the AST-only extractor misses.
|
|
11
|
+
*
|
|
12
|
+
* Costs:
|
|
13
|
+
* - ts.Program over a medium-sized repo: hundreds of ms to a few s.
|
|
14
|
+
* - Memory: roughly proportional to total .ts size.
|
|
15
|
+
*
|
|
16
|
+
* Opt-in only — the AST-only `extractApiSurface(snap)` remains the
|
|
17
|
+
* default path. This extractor is what `--with-signatures` invokes.
|
|
18
|
+
*/
|
|
19
|
+
export function extractApiSurfaceWithProgram(options) {
|
|
20
|
+
const start = Date.now();
|
|
21
|
+
const timeBudgetMs = options.timeBudgetMs ?? 60_000;
|
|
22
|
+
const diagnostics = [];
|
|
23
|
+
const tsconfigPath = resolveTsconfig(options.projectRoot, options.tsconfigPath);
|
|
24
|
+
if (!tsconfigPath) {
|
|
25
|
+
return emptyResult(start, options.projectRoot, diagnostics, 0, `no tsconfig found under ${options.projectRoot} (looked for tsconfig.base.json, tsconfig.json)`);
|
|
26
|
+
}
|
|
27
|
+
const program = buildProgram(tsconfigPath, options.projectRoot, diagnostics);
|
|
28
|
+
if (!program) {
|
|
29
|
+
return emptyResult(start, options.projectRoot, diagnostics, 0, `failed to build ts.Program from ${tsconfigPath}`);
|
|
30
|
+
}
|
|
31
|
+
const checker = program.getTypeChecker();
|
|
32
|
+
const fileToPackage = buildFileToPackageMap(options.projectRoot);
|
|
33
|
+
const filter = options.packageFilter && options.packageFilter.length > 0
|
|
34
|
+
? new Set(options.packageFilter)
|
|
35
|
+
: undefined;
|
|
36
|
+
// Signature cache (file SHA1 → per-symbol signature) — load once,
|
|
37
|
+
// write back at the end. Cache disabled when `options.useCache === false`.
|
|
38
|
+
const cacheEnabled = options.useCache !== false;
|
|
39
|
+
const oldCache = cacheEnabled ? loadSignatureCache(options.projectRoot) : emptyCache();
|
|
40
|
+
const newCacheFiles = {};
|
|
41
|
+
let cacheHits = 0;
|
|
42
|
+
let cacheMisses = 0;
|
|
43
|
+
let filesReused = 0;
|
|
44
|
+
const symbols = [];
|
|
45
|
+
let filesVisited = 0;
|
|
46
|
+
for (const sf of program.getSourceFiles()) {
|
|
47
|
+
if (sf.isDeclarationFile && !sf.fileName.includes(options.projectRoot))
|
|
48
|
+
continue;
|
|
49
|
+
if (sf.fileName.includes('/node_modules/'))
|
|
50
|
+
continue;
|
|
51
|
+
if (!sf.fileName.startsWith(options.projectRoot))
|
|
52
|
+
continue;
|
|
53
|
+
if (Date.now() - start > timeBudgetMs) {
|
|
54
|
+
diagnostics.push(`time budget exceeded after visiting ${filesVisited} files; surface is partial`);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
filesVisited += 1;
|
|
58
|
+
const relPath = nodePath
|
|
59
|
+
.relative(options.projectRoot, sf.fileName)
|
|
60
|
+
.split(nodePath.sep)
|
|
61
|
+
.join('/');
|
|
62
|
+
const pkg = lookupPackage(relPath, fileToPackage);
|
|
63
|
+
if (filter && (!pkg || !filter.has(pkg)))
|
|
64
|
+
continue;
|
|
65
|
+
// Compute the file SHA1 once and decide whether the cache is
|
|
66
|
+
// usable for this file. We still walk the module's exports below
|
|
67
|
+
// — the cache only short-circuits the per-symbol type checker
|
|
68
|
+
// call (the expensive part).
|
|
69
|
+
const fileSha = fingerprintContent(sf.getFullText());
|
|
70
|
+
const cachedEntry = cacheEnabled ? oldCache.files[relPath] : undefined;
|
|
71
|
+
const fileCacheUsable = !!cachedEntry && cachedEntry.sha1 === fileSha;
|
|
72
|
+
const fileNewSignatures = {};
|
|
73
|
+
let fileSymbolsCount = 0;
|
|
74
|
+
let fileHits = 0;
|
|
75
|
+
const moduleSymbol = checker.getSymbolAtLocation(sf);
|
|
76
|
+
if (!moduleSymbol)
|
|
77
|
+
continue;
|
|
78
|
+
const exported = checker.getExportsOfModule(moduleSymbol);
|
|
79
|
+
for (const sym of exported) {
|
|
80
|
+
// Skip module re-exports (we want declarations).
|
|
81
|
+
const decls = sym.getDeclarations() ?? [];
|
|
82
|
+
if (decls.length === 0)
|
|
83
|
+
continue;
|
|
84
|
+
const decl = decls[0];
|
|
85
|
+
const declSf = decl.getSourceFile();
|
|
86
|
+
if (declSf.fileName.includes('/node_modules/'))
|
|
87
|
+
continue;
|
|
88
|
+
const declRel = nodePath
|
|
89
|
+
.relative(options.projectRoot, declSf.fileName)
|
|
90
|
+
.split(nodePath.sep)
|
|
91
|
+
.join('/');
|
|
92
|
+
const declPkg = lookupPackage(declRel, fileToPackage);
|
|
93
|
+
if (filter && (!declPkg || !filter.has(declPkg)))
|
|
94
|
+
continue;
|
|
95
|
+
const name = sym.getName();
|
|
96
|
+
if (name === '__export')
|
|
97
|
+
continue; // synthetic, comes from `export * from`
|
|
98
|
+
const isDefault = name === 'default';
|
|
99
|
+
const kind = pickKind(decl);
|
|
100
|
+
const key = symbolCacheKey(name, isDefault);
|
|
101
|
+
let signature;
|
|
102
|
+
// Cache lookup is keyed by the file the symbol is *declared* in,
|
|
103
|
+
// not the file we're walking — they differ for re-exports.
|
|
104
|
+
const sameFile = declRel === relPath;
|
|
105
|
+
if (cacheEnabled && sameFile && fileCacheUsable && cachedEntry.signatures[key]) {
|
|
106
|
+
signature = cachedEntry.signatures[key];
|
|
107
|
+
cacheHits += 1;
|
|
108
|
+
fileHits += 1;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
signature = serializeSymbol(checker, sym, decl);
|
|
112
|
+
if (cacheEnabled)
|
|
113
|
+
cacheMisses += 1;
|
|
114
|
+
}
|
|
115
|
+
if (signature && sameFile) {
|
|
116
|
+
fileNewSignatures[key] = signature;
|
|
117
|
+
}
|
|
118
|
+
fileSymbolsCount += 1;
|
|
119
|
+
symbols.push({
|
|
120
|
+
id: `symbol:${declRel}#${name}`,
|
|
121
|
+
name,
|
|
122
|
+
kind,
|
|
123
|
+
file: declRel,
|
|
124
|
+
...(declPkg ? { package: declPkg } : {}),
|
|
125
|
+
isDefault,
|
|
126
|
+
...(signature ? { signature } : {}),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (cacheEnabled) {
|
|
130
|
+
newCacheFiles[relPath] = { sha1: fileSha, signatures: fileNewSignatures };
|
|
131
|
+
if (fileSymbolsCount > 0 && fileHits === fileSymbolsCount)
|
|
132
|
+
filesReused += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// De-dupe by `id` — re-exports can surface the same declaration
|
|
136
|
+
// multiple times.
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const deduped = [];
|
|
139
|
+
for (const s of symbols) {
|
|
140
|
+
if (seen.has(s.id))
|
|
141
|
+
continue;
|
|
142
|
+
seen.add(s.id);
|
|
143
|
+
deduped.push(s);
|
|
144
|
+
}
|
|
145
|
+
deduped.sort((a, b) => a.id.localeCompare(b.id));
|
|
146
|
+
const counts = {};
|
|
147
|
+
for (const s of deduped) {
|
|
148
|
+
const key = s.package ?? '<no-package>';
|
|
149
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
150
|
+
}
|
|
151
|
+
const surface = {
|
|
152
|
+
schema: API_SURFACE_SCHEMA,
|
|
153
|
+
projectRoot: options.projectRoot,
|
|
154
|
+
...(options.packageFilter && options.packageFilter.length > 0 ? { packageFilter: options.packageFilter } : {}),
|
|
155
|
+
symbols: deduped,
|
|
156
|
+
countsByPackage: counts,
|
|
157
|
+
total: deduped.length,
|
|
158
|
+
};
|
|
159
|
+
if (cacheEnabled) {
|
|
160
|
+
saveSignatureCache(options.projectRoot, {
|
|
161
|
+
schema: oldCache.schema,
|
|
162
|
+
generatedAt: oldCache.generatedAt, // overwritten by saveSignatureCache
|
|
163
|
+
files: newCacheFiles,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
surface,
|
|
168
|
+
diagnostics,
|
|
169
|
+
filesVisited,
|
|
170
|
+
durationMs: Date.now() - start,
|
|
171
|
+
cacheStats: {
|
|
172
|
+
enabled: cacheEnabled,
|
|
173
|
+
hits: cacheHits,
|
|
174
|
+
misses: cacheMisses,
|
|
175
|
+
filesReused,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function resolveTsconfig(projectRoot, explicit) {
|
|
180
|
+
if (explicit) {
|
|
181
|
+
const abs = nodePath.isAbsolute(explicit) ? explicit : nodePath.resolve(projectRoot, explicit);
|
|
182
|
+
return existsSync(abs) ? abs : undefined;
|
|
183
|
+
}
|
|
184
|
+
for (const name of ['tsconfig.base.json', 'tsconfig.json']) {
|
|
185
|
+
const cand = nodePath.join(projectRoot, name);
|
|
186
|
+
if (existsSync(cand))
|
|
187
|
+
return cand;
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
function buildProgram(tsconfigPath, projectRoot, diagnostics) {
|
|
192
|
+
const raw = ts.readConfigFile(tsconfigPath, (p) => readFileSync(p, 'utf8'));
|
|
193
|
+
if (raw.error) {
|
|
194
|
+
diagnostics.push(`tsconfig read error: ${ts.flattenDiagnosticMessageText(raw.error.messageText, '\n')}`);
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, nodePath.dirname(tsconfigPath));
|
|
198
|
+
for (const d of parsed.errors) {
|
|
199
|
+
diagnostics.push(`tsconfig parse: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`);
|
|
200
|
+
}
|
|
201
|
+
// Add the workspace's own source roots in case tsconfig.base.json
|
|
202
|
+
// doesn't list them (the SharkCraft monorepo's tsconfig.base.json
|
|
203
|
+
// uses `include: ['packages/*/src/**/*.ts']` already).
|
|
204
|
+
return ts.createProgram({
|
|
205
|
+
rootNames: parsed.fileNames,
|
|
206
|
+
options: {
|
|
207
|
+
...parsed.options,
|
|
208
|
+
// Forcibly disable emit / type errors that don't matter for symbol
|
|
209
|
+
// discovery.
|
|
210
|
+
noEmit: true,
|
|
211
|
+
skipLibCheck: true,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function pickKind(decl) {
|
|
216
|
+
if (ts.isClassDeclaration(decl))
|
|
217
|
+
return 'class';
|
|
218
|
+
if (ts.isFunctionDeclaration(decl))
|
|
219
|
+
return 'function';
|
|
220
|
+
if (ts.isInterfaceDeclaration(decl))
|
|
221
|
+
return 'interface';
|
|
222
|
+
if (ts.isTypeAliasDeclaration(decl))
|
|
223
|
+
return 'type-alias';
|
|
224
|
+
if (ts.isEnumDeclaration(decl))
|
|
225
|
+
return 'enum';
|
|
226
|
+
if (ts.isVariableDeclaration(decl)) {
|
|
227
|
+
const list = decl.parent;
|
|
228
|
+
if (list && list.flags & ts.NodeFlags.Const)
|
|
229
|
+
return 'const';
|
|
230
|
+
if (list && list.flags & ts.NodeFlags.Let)
|
|
231
|
+
return 'let';
|
|
232
|
+
return 'var';
|
|
233
|
+
}
|
|
234
|
+
if (ts.isModuleDeclaration(decl)) {
|
|
235
|
+
if (decl.flags & ts.NodeFlags.Namespace)
|
|
236
|
+
return 'namespace';
|
|
237
|
+
return 'module';
|
|
238
|
+
}
|
|
239
|
+
return 'unknown';
|
|
240
|
+
}
|
|
241
|
+
const TYPE_FORMAT_FLAGS = ts.TypeFormatFlags.NoTruncation |
|
|
242
|
+
ts.TypeFormatFlags.WriteArrayAsGenericType |
|
|
243
|
+
ts.TypeFormatFlags.UseStructuralFallback |
|
|
244
|
+
ts.TypeFormatFlags.InTypeAlias;
|
|
245
|
+
function serializeSymbol(checker, sym, decl) {
|
|
246
|
+
try {
|
|
247
|
+
const allDecls = sym.getDeclarations() ?? [decl];
|
|
248
|
+
const typeParamNames = extractTypeParameterNames(allDecls);
|
|
249
|
+
// Type-shape symbols (interface / type alias / class) need
|
|
250
|
+
// structural serialization — `typeToString` would just print the
|
|
251
|
+
// symbol's own name (`IUser`) and miss member changes. Walk the
|
|
252
|
+
// properties explicitly so two interfaces with different member
|
|
253
|
+
// lists serialize to different strings.
|
|
254
|
+
if (sym.flags & (ts.SymbolFlags.Interface | ts.SymbolFlags.Class)) {
|
|
255
|
+
return normalizeGenerics(serializeStructuralType(checker, sym, decl), typeParamNames);
|
|
256
|
+
}
|
|
257
|
+
if (sym.flags & ts.SymbolFlags.Enum) {
|
|
258
|
+
return serializeEnum(checker, sym);
|
|
259
|
+
}
|
|
260
|
+
if (sym.flags & ts.SymbolFlags.TypeAlias) {
|
|
261
|
+
const type = checker.getDeclaredTypeOfSymbol(sym);
|
|
262
|
+
// For type aliases, typeToString *does* expand most shapes — but
|
|
263
|
+
// when the alias resolves to another named type the result is
|
|
264
|
+
// just the name. Fall back to structural for object types.
|
|
265
|
+
if (type.flags & ts.TypeFlags.Object) {
|
|
266
|
+
return normalizeGenerics(canonicalizeSignature(formatObjectType(checker, type, decl)), typeParamNames);
|
|
267
|
+
}
|
|
268
|
+
return normalizeGenerics(canonicalizeSignature(checker.typeToString(type, decl, TYPE_FORMAT_FLAGS)), typeParamNames);
|
|
269
|
+
}
|
|
270
|
+
// Value symbols (function / const / let / var): serialize the
|
|
271
|
+
// expression / call-signature type.
|
|
272
|
+
const type = checker.getTypeOfSymbolAtLocation(sym, decl);
|
|
273
|
+
const text = checker.typeToString(type, decl, TYPE_FORMAT_FLAGS);
|
|
274
|
+
return normalizeGenerics(canonicalizeSignature(text), typeParamNames);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Collect type-parameter names from a symbol's declarations, in
|
|
282
|
+
* declaration order. Used to substitute `T`/`U`/etc. with positional
|
|
283
|
+
* placeholders so rename-only refactors don't read as breaking changes
|
|
284
|
+
* in the diff.
|
|
285
|
+
*
|
|
286
|
+
* Only top-level type parameters on the declaration itself are
|
|
287
|
+
* captured; nested generic closures keep their original names (we
|
|
288
|
+
* don't have a stable positional identity for them at the symbol
|
|
289
|
+
* level).
|
|
290
|
+
*/
|
|
291
|
+
function extractTypeParameterNames(decls) {
|
|
292
|
+
for (const decl of decls) {
|
|
293
|
+
const tp = decl.typeParameters;
|
|
294
|
+
if (tp && tp.length > 0) {
|
|
295
|
+
const names = [];
|
|
296
|
+
for (const t of tp) {
|
|
297
|
+
if (ts.isIdentifier(t.name))
|
|
298
|
+
names.push(t.name.text);
|
|
299
|
+
}
|
|
300
|
+
if (names.length > 0)
|
|
301
|
+
return names;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
function normalizeGenerics(serialized, paramNames) {
|
|
307
|
+
if (paramNames.length === 0)
|
|
308
|
+
return serialized;
|
|
309
|
+
// Substitute in two passes so a rename like `T → U` doesn't collide
|
|
310
|
+
// when an output placeholder happens to match another input name.
|
|
311
|
+
// First pass: rename each input to a unique placeholder using a
|
|
312
|
+
// marker prefix that can't appear in legitimate identifiers.
|
|
313
|
+
let out = serialized;
|
|
314
|
+
for (let i = 0; i < paramNames.length; i += 1) {
|
|
315
|
+
const re = new RegExp('\\b' + escapeRegExp(paramNames[i]) + '\\b', 'g');
|
|
316
|
+
out = out.replace(re, `P${i}`);
|
|
317
|
+
}
|
|
318
|
+
// Second pass: turn the markers into stable, human-readable
|
|
319
|
+
// placeholders.
|
|
320
|
+
out = out.replace(/P(\d+)/g, '__P$1');
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
function escapeRegExp(s) {
|
|
324
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
325
|
+
}
|
|
326
|
+
function serializeStructuralType(checker, sym, decl) {
|
|
327
|
+
const type = checker.getDeclaredTypeOfSymbol(sym);
|
|
328
|
+
return canonicalizeSignature(formatObjectType(checker, type, decl));
|
|
329
|
+
}
|
|
330
|
+
function formatObjectType(checker, type, locus) {
|
|
331
|
+
const props = checker.getPropertiesOfType(type);
|
|
332
|
+
const memberStrs = [];
|
|
333
|
+
for (const prop of props) {
|
|
334
|
+
const propDecl = prop.getDeclarations()?.[0] ?? locus;
|
|
335
|
+
const propType = checker.getTypeOfSymbolAtLocation(prop, propDecl);
|
|
336
|
+
const optional = (prop.flags & ts.SymbolFlags.Optional) !== 0 ? '?' : '';
|
|
337
|
+
memberStrs.push(`${prop.getName()}${optional}: ${checker.typeToString(propType, propDecl, TYPE_FORMAT_FLAGS)}`);
|
|
338
|
+
}
|
|
339
|
+
// Call signatures (function-shaped object types).
|
|
340
|
+
for (const sig of checker.getSignaturesOfType(type, ts.SignatureKind.Call)) {
|
|
341
|
+
memberStrs.push(`(call): ${checker.signatureToString(sig, locus)}`);
|
|
342
|
+
}
|
|
343
|
+
// Construct signatures (classes / interfaces with new(...)).
|
|
344
|
+
for (const sig of checker.getSignaturesOfType(type, ts.SignatureKind.Construct)) {
|
|
345
|
+
memberStrs.push(`(new): ${checker.signatureToString(sig, locus)}`);
|
|
346
|
+
}
|
|
347
|
+
memberStrs.sort();
|
|
348
|
+
return `{ ${memberStrs.join('; ')} }`;
|
|
349
|
+
}
|
|
350
|
+
function serializeEnum(checker, sym) {
|
|
351
|
+
const decls = sym.getDeclarations() ?? [];
|
|
352
|
+
const members = [];
|
|
353
|
+
for (const decl of decls) {
|
|
354
|
+
if (!ts.isEnumDeclaration(decl))
|
|
355
|
+
continue;
|
|
356
|
+
for (const member of decl.members) {
|
|
357
|
+
if (ts.isIdentifier(member.name)) {
|
|
358
|
+
members.push(member.name.text);
|
|
359
|
+
}
|
|
360
|
+
else if (ts.isStringLiteral(member.name)) {
|
|
361
|
+
members.push(`'${member.name.text}'`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
members.sort();
|
|
366
|
+
return canonicalizeSignature(`enum { ${members.join(', ')} }`);
|
|
367
|
+
// (Silence unused-import warning if checker isn't referenced.)
|
|
368
|
+
void checker;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Normalize whitespace so two semantically-equivalent signatures
|
|
372
|
+
* compare equal across tsserver / compiler version drift.
|
|
373
|
+
*/
|
|
374
|
+
function canonicalizeSignature(s) {
|
|
375
|
+
return s.replace(/\s+/g, ' ').replace(/\s*([,;:<>(){}|&=])\s*/g, '$1').trim();
|
|
376
|
+
}
|
|
377
|
+
function buildFileToPackageMap(projectRoot) {
|
|
378
|
+
const out = new Map();
|
|
379
|
+
try {
|
|
380
|
+
const pkgJson = nodePath.join(projectRoot, 'package.json');
|
|
381
|
+
const root = existsSync(pkgJson) ? JSON.parse(readFileSync(pkgJson, 'utf8')) : {};
|
|
382
|
+
const patterns = normalizeWorkspaces(root.workspaces);
|
|
383
|
+
for (const pattern of patterns) {
|
|
384
|
+
const dir = pattern.replace(/\/\*?$/, '');
|
|
385
|
+
const full = nodePath.join(projectRoot, dir);
|
|
386
|
+
if (!existsSync(full))
|
|
387
|
+
continue;
|
|
388
|
+
let children;
|
|
389
|
+
try {
|
|
390
|
+
children = readdirSync(full);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
for (const child of children) {
|
|
396
|
+
const inner = nodePath.join(full, child);
|
|
397
|
+
const childPkg = nodePath.join(inner, 'package.json');
|
|
398
|
+
if (!existsSync(childPkg))
|
|
399
|
+
continue;
|
|
400
|
+
try {
|
|
401
|
+
const pj = JSON.parse(readFileSync(childPkg, 'utf8'));
|
|
402
|
+
if (pj.name) {
|
|
403
|
+
const rel = nodePath.relative(projectRoot, inner).split(nodePath.sep).join('/');
|
|
404
|
+
out.set(rel, pj.name);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* ignore */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
/* ignore */
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
function lookupPackage(relPath, fileToPackage) {
|
|
419
|
+
// Walk path prefixes; pick the longest match.
|
|
420
|
+
const parts = relPath.split('/');
|
|
421
|
+
for (let i = parts.length - 1; i > 0; i -= 1) {
|
|
422
|
+
const prefix = parts.slice(0, i).join('/');
|
|
423
|
+
const hit = fileToPackage.get(prefix);
|
|
424
|
+
if (hit)
|
|
425
|
+
return hit;
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
function normalizeWorkspaces(value) {
|
|
430
|
+
if (!value)
|
|
431
|
+
return [];
|
|
432
|
+
if (Array.isArray(value))
|
|
433
|
+
return value.filter((v) => typeof v === 'string');
|
|
434
|
+
if (typeof value === 'object') {
|
|
435
|
+
const packages = value.packages;
|
|
436
|
+
if (Array.isArray(packages))
|
|
437
|
+
return packages.filter((v) => typeof v === 'string');
|
|
438
|
+
}
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
function emptyResult(start, projectRoot, diagnostics, filesVisited, message) {
|
|
442
|
+
diagnostics.push(message);
|
|
443
|
+
return {
|
|
444
|
+
surface: {
|
|
445
|
+
schema: API_SURFACE_SCHEMA,
|
|
446
|
+
projectRoot,
|
|
447
|
+
symbols: [],
|
|
448
|
+
countsByPackage: {},
|
|
449
|
+
total: 0,
|
|
450
|
+
},
|
|
451
|
+
diagnostics,
|
|
452
|
+
filesVisited,
|
|
453
|
+
durationMs: Date.now() - start,
|
|
454
|
+
cacheStats: { enabled: false, hits: 0, misses: 0, filesReused: 0 },
|
|
455
|
+
};
|
|
456
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type IGraphSnapshot } from '@shrkcrft/graph';
|
|
2
|
+
import { type IApiSurface } from '../schema/api-surface.js';
|
|
3
|
+
export interface IExtractSurfaceOptions {
|
|
4
|
+
/** Restrict surface to these package names. */
|
|
5
|
+
packageFilter?: readonly string[];
|
|
6
|
+
/** Include only declared symbols (excludes re-exports). Default true. */
|
|
7
|
+
includeDeclaredOnly?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Walk a graph snapshot and produce a deterministic `IApiSurface`.
|
|
11
|
+
*
|
|
12
|
+
* A "public" symbol is any `symbol:` node whose `data.isExported`
|
|
13
|
+
* is true. The owning file (and its owning package) are resolved via
|
|
14
|
+
* the symbol node id (`symbol:<filePath>#<name>`) and the file's
|
|
15
|
+
* BelongsToPackage edge.
|
|
16
|
+
*/
|
|
17
|
+
export declare function extractApiSurface(snap: IGraphSnapshot, options?: IExtractSurfaceOptions): IApiSurface;
|
|
18
|
+
//# sourceMappingURL=extract-surface.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extract-surface.d.ts","sourceRoot":"","sources":["../../src/engine/extract-surface.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EAEpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAGL,KAAK,WAAW,EAEjB,MAAM,0BAA0B,CAAC;AAElC,MAAM,WAAW,sBAAsB;IACrC,+CAA+C;IAC/C,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,yEAAyE;IACzE,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,cAAc,EACpB,OAAO,GAAE,sBAA2B,GACnC,WAAW,CAkDb"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { EdgeKind, NodeKind, } from '@shrkcrft/graph';
|
|
2
|
+
import { API_SURFACE_SCHEMA, } from "../schema/api-surface.js";
|
|
3
|
+
/**
|
|
4
|
+
* Walk a graph snapshot and produce a deterministic `IApiSurface`.
|
|
5
|
+
*
|
|
6
|
+
* A "public" symbol is any `symbol:` node whose `data.isExported`
|
|
7
|
+
* is true. The owning file (and its owning package) are resolved via
|
|
8
|
+
* the symbol node id (`symbol:<filePath>#<name>`) and the file's
|
|
9
|
+
* BelongsToPackage edge.
|
|
10
|
+
*/
|
|
11
|
+
export function extractApiSurface(snap, options = {}) {
|
|
12
|
+
const filter = options.packageFilter && options.packageFilter.length > 0
|
|
13
|
+
? new Set(options.packageFilter)
|
|
14
|
+
: undefined;
|
|
15
|
+
// Build file → package map from BelongsToPackage edges.
|
|
16
|
+
const fileToPackage = new Map();
|
|
17
|
+
for (const e of snap.edges.values()) {
|
|
18
|
+
if (e.kind !== EdgeKind.BelongsToPackage)
|
|
19
|
+
continue;
|
|
20
|
+
const fileId = e.from;
|
|
21
|
+
const pkgId = e.to;
|
|
22
|
+
if (!fileId.startsWith('file:') || !pkgId.startsWith('package:'))
|
|
23
|
+
continue;
|
|
24
|
+
fileToPackage.set(fileId, pkgId.slice('package:'.length));
|
|
25
|
+
}
|
|
26
|
+
const symbols = [];
|
|
27
|
+
for (const node of snap.nodes.values()) {
|
|
28
|
+
if (node.kind !== NodeKind.Symbol)
|
|
29
|
+
continue;
|
|
30
|
+
const exported = (node.data?.['isExported'] ?? false) === true;
|
|
31
|
+
if (!exported)
|
|
32
|
+
continue;
|
|
33
|
+
const file = node.path;
|
|
34
|
+
if (!file)
|
|
35
|
+
continue;
|
|
36
|
+
const fileId = `file:${file}`;
|
|
37
|
+
const pkg = fileToPackage.get(fileId);
|
|
38
|
+
if (filter && (!pkg || !filter.has(pkg)))
|
|
39
|
+
continue;
|
|
40
|
+
const visibility = node.data?.['visibility'] ?? '';
|
|
41
|
+
const isDefault = visibility === 'default';
|
|
42
|
+
symbols.push({
|
|
43
|
+
id: node.id,
|
|
44
|
+
name: node.label,
|
|
45
|
+
kind: (node.data?.['declKind'] ?? 'unknown'),
|
|
46
|
+
file,
|
|
47
|
+
...(pkg ? { package: pkg } : {}),
|
|
48
|
+
isDefault,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
symbols.sort((a, b) => a.id.localeCompare(b.id));
|
|
52
|
+
const counts = {};
|
|
53
|
+
for (const s of symbols) {
|
|
54
|
+
const key = s.package ?? '<no-package>';
|
|
55
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
schema: API_SURFACE_SCHEMA,
|
|
59
|
+
projectRoot: snap.manifest.projectRoot,
|
|
60
|
+
...(options.packageFilter && options.packageFilter.length > 0 ? { packageFilter: options.packageFilter } : {}),
|
|
61
|
+
symbols,
|
|
62
|
+
countsByPackage: counts,
|
|
63
|
+
total: symbols.length,
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from './schema/api-surface.js';
|
|
2
|
+
export * from './engine/extract-surface.js';
|
|
3
|
+
export * from './engine/extract-surface-with-program.js';
|
|
4
|
+
export * from './engine/diff-surfaces.js';
|
|
5
|
+
export * from './cache/signature-cache.js';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,yBAAyB,CAAC;AACxC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,0CAA0C,CAAC;AACzD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,4BAA4B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-API surface snapshot for a workspace package or the whole
|
|
3
|
+
* repo. Produced from a `@shrkcrft/graph` snapshot; consumed by the
|
|
4
|
+
* diff engine to detect breaking changes.
|
|
5
|
+
*/
|
|
6
|
+
export declare const API_SURFACE_SCHEMA: "sharkcraft.api-surface/v1";
|
|
7
|
+
export declare const API_SURFACE_DIFF_SCHEMA: "sharkcraft.api-surface-diff/v1";
|
|
8
|
+
export type ApiSymbolKind = 'class' | 'function' | 'interface' | 'type-alias' | 'enum' | 'const' | 'let' | 'var' | 'module' | 'namespace' | 'unknown';
|
|
9
|
+
export interface IPublicSymbol {
|
|
10
|
+
/** Symbol node id (e.g. `symbol:packages/foo/src/index.ts#myFn`). */
|
|
11
|
+
id: string;
|
|
12
|
+
/** Symbol name as exposed by the file. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Declaration kind. */
|
|
15
|
+
kind: ApiSymbolKind;
|
|
16
|
+
/** File where the symbol is declared. */
|
|
17
|
+
file: string;
|
|
18
|
+
/** Workspace package name (if known). */
|
|
19
|
+
package?: string;
|
|
20
|
+
/** True for default exports. */
|
|
21
|
+
isDefault: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Canonical signature string from the TS type checker, when
|
|
24
|
+
* extracted via `extractApiSurfaceWithProgram`. Absent for surfaces
|
|
25
|
+
* captured from the AST-only graph snapshot.
|
|
26
|
+
*/
|
|
27
|
+
signature?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface IApiSurface {
|
|
30
|
+
schema: typeof API_SURFACE_SCHEMA;
|
|
31
|
+
/** Top-level project root (POSIX-normalised) when known. */
|
|
32
|
+
projectRoot?: string;
|
|
33
|
+
/** When set, only entries under one of these packages are included. */
|
|
34
|
+
packageFilter?: readonly string[];
|
|
35
|
+
/** Symbols sorted by id ascending. */
|
|
36
|
+
symbols: readonly IPublicSymbol[];
|
|
37
|
+
/** Per-package summary counts. */
|
|
38
|
+
countsByPackage: Readonly<Record<string, number>>;
|
|
39
|
+
/** Total symbol count. */
|
|
40
|
+
total: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Severity of a single diff entry.
|
|
44
|
+
*
|
|
45
|
+
* - `breaking`: a public symbol was removed, OR its kind changed in a
|
|
46
|
+
* way that breaks consumer code (e.g. class → function).
|
|
47
|
+
* - `additive`: a new symbol was added, OR a non-breaking detail
|
|
48
|
+
* changed (e.g. moved file within the same package).
|
|
49
|
+
* - `info`: cosmetic / structural change only.
|
|
50
|
+
*/
|
|
51
|
+
export type DiffSeverity = 'breaking' | 'additive' | 'info';
|
|
52
|
+
export type DiffChangeKind = 'added' | 'removed' | 'kind-changed' | 'moved-file' | 'moved-package' | 'signature-changed';
|
|
53
|
+
export interface IApiSymbolDiff {
|
|
54
|
+
kind: DiffChangeKind;
|
|
55
|
+
severity: DiffSeverity;
|
|
56
|
+
/** Stable, human-readable summary. */
|
|
57
|
+
message: string;
|
|
58
|
+
symbol: IPublicSymbol;
|
|
59
|
+
/** Old version of the symbol for changed entries. */
|
|
60
|
+
previous?: IPublicSymbol;
|
|
61
|
+
}
|
|
62
|
+
export interface IApiSurfaceDiff {
|
|
63
|
+
schema: typeof API_SURFACE_DIFF_SCHEMA;
|
|
64
|
+
baselineTotal: number;
|
|
65
|
+
currentTotal: number;
|
|
66
|
+
added: number;
|
|
67
|
+
removed: number;
|
|
68
|
+
changed: number;
|
|
69
|
+
/** Total breaking-severity entries. */
|
|
70
|
+
breakingCount: number;
|
|
71
|
+
/** Diff entries, sorted: breaking → additive → info. */
|
|
72
|
+
entries: readonly IApiSymbolDiff[];
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=api-surface.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-surface.d.ts","sourceRoot":"","sources":["../../src/schema/api-surface.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,EAAG,2BAAoC,CAAC;AACvE,eAAO,MAAM,uBAAuB,EAAG,gCAAyC,CAAC;AAEjF,MAAM,MAAM,aAAa,GACrB,OAAO,GACP,UAAU,GACV,WAAW,GACX,YAAY,GACZ,MAAM,GACN,OAAO,GACP,KAAK,GACL,KAAK,GACL,QAAQ,GACR,WAAW,GACX,SAAS,CAAC;AAEd,MAAM,WAAW,aAAa;IAC5B,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;IACX,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,IAAI,EAAE,aAAa,CAAC;IACpB,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,yCAAyC;IACzC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gCAAgC;IAChC,SAAS,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,OAAO,kBAAkB,CAAC;IAClC,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uEAAuE;IACvE,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,sCAAsC;IACtC,OAAO,EAAE,SAAS,aAAa,EAAE,CAAC;IAClC,kCAAkC;IAClC,eAAe,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD,0BAA0B;IAC1B,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,UAAU,GAAG,MAAM,CAAC;AAE5D,MAAM,MAAM,cAAc,GACtB,OAAO,GACP,SAAS,GACT,cAAc,GACd,YAAY,GACZ,eAAe,GACf,mBAAmB,CAAC;AAExB,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,cAAc,CAAC;IACrB,QAAQ,EAAE,YAAY,CAAC;IACvB,sCAAsC;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,aAAa,CAAC;IACtB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,OAAO,uBAAuB,CAAC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,uCAAuC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,OAAO,EAAE,SAAS,cAAc,EAAE,CAAC;CACpC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-API surface snapshot for a workspace package or the whole
|
|
3
|
+
* repo. Produced from a `@shrkcrft/graph` snapshot; consumed by the
|
|
4
|
+
* diff engine to detect breaking changes.
|
|
5
|
+
*/
|
|
6
|
+
export const API_SURFACE_SCHEMA = 'sharkcraft.api-surface/v1';
|
|
7
|
+
export const API_SURFACE_DIFF_SCHEMA = 'sharkcraft.api-surface-diff/v1';
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shrkcrft/api-surface-diff",
|
|
3
|
+
"version": "0.1.0-alpha.10",
|
|
4
|
+
"description": "SharkCraft API surface diff: compare two code-graph snapshots and report added / removed / changed public symbols. Catches accidental API breaks before publish.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "SharkCraft contributors",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/sharkcraft/sharkcraft.git",
|
|
25
|
+
"directory": "packages/api-surface-diff"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/sharkcraft/sharkcraft",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/sharkcraft/sharkcraft/issues"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"sharkcraft",
|
|
33
|
+
"api-surface",
|
|
34
|
+
"diff",
|
|
35
|
+
"code-intelligence",
|
|
36
|
+
"breaking-change"
|
|
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.10",
|
|
47
|
+
"@shrkcrft/graph": "^0.1.0-alpha.10",
|
|
48
|
+
"typescript": "^5.5.0"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|