@gpzhang2001/sharpkit-analysis 0.2.1

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/src/stores.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Run-scoped JSON mirror stores (strix .state/ mirrors): atomic writes
3
+ * (temp-in-dir + rename), hydrate-on-start, per-store in-memory maps.
4
+ * One instance per scan, owned by the analysis service.
5
+ * @module @gpzhang2001/sharpkit-analysis/stores
6
+ */
7
+
8
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
9
+ import { dirname, join } from 'node:path'
10
+
11
+ /** A JSON file mirror with atomic write and hydrate. */
12
+ export class JsonMirrorStore {
13
+ private readonly path: string | null
14
+ private readonly map = new Map<string, Record<string, unknown>>()
15
+
16
+ constructor(path: string | null) {
17
+ this.path = path
18
+ }
19
+
20
+ /** Load the mirror file; corrupt JSON is tolerated as empty on hydrate (resume aid only). */
21
+ async hydrate(): Promise<void> {
22
+ if (this.path === null) return
23
+ try {
24
+ const raw = await readFile(this.path, 'utf8')
25
+ const parsed = JSON.parse(raw) as unknown
26
+ if (typeof parsed !== 'object' || parsed === null) return
27
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
28
+ if (typeof value === 'object' && value !== null) this.map.set(key, value as Record<string, unknown>)
29
+ }
30
+ } catch {
31
+ // Missing or unreadable mirror: start empty.
32
+ }
33
+ }
34
+
35
+ /** Persist the whole map atomically (strix `_persist_locked`). */
36
+ async persist(): Promise<void> {
37
+ if (this.path === null) return
38
+ const payload = JSON.stringify(Object.fromEntries(this.map), null, 2)
39
+ await mkdir(dirname(this.path), { recursive: true })
40
+ const temp = `${dirname(this.path)}/.${join('', this.path.split('/').pop() ?? 'store')}.${process.pid}.tmp`
41
+ await writeFile(temp, payload, 'utf8')
42
+ await rename(temp, this.path)
43
+ }
44
+
45
+ get(key: string): Record<string, unknown> | undefined {
46
+ return this.map.get(key)
47
+ }
48
+
49
+ set(key: string, value: Record<string, unknown>): void {
50
+ this.map.set(key, value)
51
+ }
52
+
53
+ delete(key: string): boolean {
54
+ return this.map.delete(key)
55
+ }
56
+
57
+ values(): Record<string, unknown>[] {
58
+ return [...this.map.values()]
59
+ }
60
+
61
+ get size(): number {
62
+ return this.map.size
63
+ }
64
+ }
65
+
66
+ /** Generate a 6-hex id with bounded collision retries (strix parity). */
67
+ export function generateId(existing: ReadonlySet<string>): string | null {
68
+ for (let attempt = 0; attempt < 1024; attempt++) {
69
+ const id = Math.random().toString(16).slice(2, 8).padEnd(6, '0')
70
+ if (!existing.has(id)) return id
71
+ }
72
+ return null
73
+ }
74
+
75
+ /**
76
+ * Normalize a remote target to its identity (threat_model/tools.py
77
+ * `_normalize_remote_target` + `_normalize_git_remote`): scp-style and
78
+ * scheme'd git remotes map to https URLs, `.git` stripped, trailing slash
79
+ * stripped, host lowercased with default ports collapsed.
80
+ * @param target - the raw target string.
81
+ */
82
+ export function normalizeTargetIdentity(target: string): string {
83
+ const trimmed = target.trim()
84
+ const scp = /^git@([^:]+):(.+?)(?:\.git)?$/.exec(trimmed)
85
+ if (scp !== null) return `https://${scp[1]?.toLowerCase()}/${scp[2]}`
86
+ const withScheme = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]+)([^#]*)/i.exec(trimmed)
87
+ if (withScheme !== null) {
88
+ const scheme = withScheme[1] ?? ''
89
+ const authority = withScheme[2] ?? ''
90
+ const path = withScheme[3] ?? ''
91
+ const defaultPort = scheme.toLowerCase() === 'https' ? '443' : scheme.toLowerCase() === 'http' ? '80' : null
92
+ let host = authority.toLowerCase()
93
+ if (defaultPort !== null && host.endsWith(`:${defaultPort}`)) host = host.slice(0, -defaultPort.length - 1)
94
+ return `https://${host}${path.replace(/\/$/, '')}`.replace(/\.git$/, '')
95
+ }
96
+ return trimmed.replace(/\/$/, '').replace(/\.git$/, '')
97
+ }