rastack 0.0.16 → 0.0.20
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/CHANGELOG.md +43876 -43013
- package/dist/.prettierrc +7 -7
- package/dist/compile/analyze.js +19 -11
- package/dist/compile/index.js +26 -17
- package/dist/compile/manifest.js +2 -3
- package/dist/compile/openapi.js +5 -8
- package/dist/compile/program.js +19 -10
- package/dist/csv-schema.js +5 -6
- package/dist/define/index.js +2 -2
- package/dist/entity-generation/delete-method.js +8 -10
- package/dist/entity-generation/form.js +80 -82
- package/dist/entity-generation/get-method.js +35 -36
- package/dist/entity-generation/imports.js +24 -20
- package/dist/entity-generation/sync-method.d.ts +1 -1
- package/dist/entity-generation/sync-method.js +45 -46
- package/dist/entity-generation/update-method.js +9 -10
- package/dist/rad-wasm-build.d.ts +1 -1
- package/dist/rad-wasm-build.js +15 -6
- package/dist/rad.js +9 -2
- package/dist/schema/camel-to-pastel.js +1 -2
- package/dist/schema/capitalise-first-letter.js +1 -2
- package/dist/schema/extract-response.js +3 -4
- package/dist/schema/fetch-schema.js +4 -16
- package/dist/schema/remove-non-model-paths.js +1 -2
- package/dist/schema/to-camel-case.js +1 -2
- package/dist/schema/to-pastel-case.js +1 -2
- package/dist/schema-convert.js +22 -33
- package/dist/schema-entities.js +177 -189
- package/dist/schema-fetch.js +46 -63
- package/dist/schema-full.js +23 -36
- package/dist/schema-index.js +16 -27
- package/dist/schema-params.js +11 -22
- package/dist/seed.js +95 -97
- package/hooks/form/form.ts +207 -207
- package/hooks/form/index.ts +8 -8
- package/hooks/form/interfaces.ts +217 -217
- package/hooks/form/structure.ts +39 -39
- package/hooks/form/validate-schema.ts +49 -49
- package/hooks/index.ts +3 -3
- package/hooks/query/api.ts +42 -42
- package/hooks/query/delete.ts +45 -45
- package/hooks/query/fetch.ts +48 -48
- package/hooks/query/index.ts +21 -21
- package/hooks/query/interfaces.ts +111 -111
- package/hooks/query/list.ts +286 -286
- package/hooks/query/update.ts +88 -88
- package/hooks/query/url.ts +31 -31
- package/hooks/real-time/index.ts +1 -1
- package/hooks/real-time/pusher.ts +43 -43
- package/jest.config.cjs +6 -6
- package/package.json +57 -57
- package/provider/index.ts +13 -13
- package/provider/provider.tsx +187 -187
- package/provider/types.ts +114 -114
- package/provider/warehouse.ts +167 -167
- package/provider/wasm.ts +124 -124
- package/runtime.ts +3 -3
- package/src/.prettierrc +7 -7
- package/src/compile/analyze.ts +224 -224
- package/src/compile/index.ts +86 -86
- package/src/compile/manifest.ts +10 -10
- package/src/compile/model.ts +69 -69
- package/src/compile/openapi.ts +266 -266
- package/src/compile/program.ts +40 -40
- package/src/csv-schema.ts +187 -187
- package/src/define/index.ts +162 -162
- package/src/entity-generation/delete-method.ts +37 -37
- package/src/entity-generation/form.ts +169 -169
- package/src/entity-generation/get-method.ts +80 -80
- package/src/entity-generation/imports.ts +110 -110
- package/src/entity-generation/sync-method.ts +222 -222
- package/src/entity-generation/update-method.ts +38 -38
- package/src/index.ts +77 -77
- package/src/rad-compile.ts +88 -88
- package/src/rad-wasm-build.ts +102 -102
- package/src/rad.ts +101 -101
- package/src/scan.ts +89 -89
- package/src/schema/camel-to-pastel.ts +3 -3
- package/src/schema/capitalise-first-letter.ts +3 -3
- package/src/schema/extract-response.ts +38 -38
- package/src/schema/fetch-schema.ts +12 -12
- package/src/schema/remove-non-model-paths.ts +13 -13
- package/src/schema/to-camel-case.ts +7 -7
- package/src/schema/to-pastel-case.ts +6 -6
- package/src/schema-convert.ts +61 -61
- package/src/schema-entities.ts +401 -401
- package/src/schema-fetch.ts +82 -82
- package/src/schema-full.ts +38 -38
- package/src/schema-index.ts +29 -29
- package/src/schema-params.ts +116 -116
- package/src/seed.ts +297 -297
- package/sync/engine.ts +392 -392
- package/sync/hooks.ts +450 -450
- package/sync/index.ts +8 -8
- package/sync/persistence.ts +237 -237
- package/sync/provider.tsx +91 -91
- package/sync/registry.ts +36 -36
- package/sync/store.ts +126 -126
- package/sync/transactions.ts +300 -300
- package/sync/types.ts +94 -94
- package/test/compile.spec.ts +192 -192
- package/test/csv-schema.spec.ts +143 -143
- package/test/schema-entities.spec.ts +688 -688
- package/tsconfig.json +24 -24
- package/types.ts +18 -18
package/src/compile/program.ts
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
import * as path from "path";
|
|
2
|
-
import * as ts from "typescript";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Build a `ts.Program` (and thus a `TypeChecker`) over a set of resource
|
|
6
|
-
* definition files. The DSL package (`rad-tools/define`) is mapped to the
|
|
7
|
-
* in-tree/installed sources via `paths` so both the shipped package name and
|
|
8
|
-
* the local demo/tests resolve without extra linking.
|
|
9
|
-
*/
|
|
10
|
-
export function createProgram(fileNames: string[]): ts.Program {
|
|
11
|
-
const defineDir = path.resolve(__dirname, "../define");
|
|
12
|
-
|
|
13
|
-
const options: ts.CompilerOptions = {
|
|
14
|
-
target: ts.ScriptTarget.ES2019,
|
|
15
|
-
module: ts.ModuleKind.CommonJS,
|
|
16
|
-
moduleResolution: ts.ModuleResolutionKind.Node10,
|
|
17
|
-
strict: true,
|
|
18
|
-
skipLibCheck: true,
|
|
19
|
-
esModuleInterop: true,
|
|
20
|
-
noEmit: true,
|
|
21
|
-
baseUrl: process.cwd(),
|
|
22
|
-
paths: {
|
|
23
|
-
"rad-tools/define": [defineDir],
|
|
24
|
-
"rad-tools/define/*": [defineDir + "/*"],
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
return ts.createProgram(fileNames, options);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Every non-declaration source file the program was rooted at. */
|
|
32
|
-
export function resourceSourceFiles(
|
|
33
|
-
program: ts.Program,
|
|
34
|
-
fileNames: string[],
|
|
35
|
-
): ts.SourceFile[] {
|
|
36
|
-
const wanted = new Set(fileNames.map((f) => path.resolve(f)));
|
|
37
|
-
return program
|
|
38
|
-
.getSourceFiles()
|
|
39
|
-
.filter((sf) => !sf.isDeclarationFile && wanted.has(path.resolve(sf.fileName)));
|
|
40
|
-
}
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a `ts.Program` (and thus a `TypeChecker`) over a set of resource
|
|
6
|
+
* definition files. The DSL package (`rad-tools/define`) is mapped to the
|
|
7
|
+
* in-tree/installed sources via `paths` so both the shipped package name and
|
|
8
|
+
* the local demo/tests resolve without extra linking.
|
|
9
|
+
*/
|
|
10
|
+
export function createProgram(fileNames: string[]): ts.Program {
|
|
11
|
+
const defineDir = path.resolve(__dirname, "../define");
|
|
12
|
+
|
|
13
|
+
const options: ts.CompilerOptions = {
|
|
14
|
+
target: ts.ScriptTarget.ES2019,
|
|
15
|
+
module: ts.ModuleKind.CommonJS,
|
|
16
|
+
moduleResolution: ts.ModuleResolutionKind.Node10,
|
|
17
|
+
strict: true,
|
|
18
|
+
skipLibCheck: true,
|
|
19
|
+
esModuleInterop: true,
|
|
20
|
+
noEmit: true,
|
|
21
|
+
baseUrl: process.cwd(),
|
|
22
|
+
paths: {
|
|
23
|
+
"rad-tools/define": [defineDir],
|
|
24
|
+
"rad-tools/define/*": [defineDir + "/*"],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return ts.createProgram(fileNames, options);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every non-declaration source file the program was rooted at. */
|
|
32
|
+
export function resourceSourceFiles(
|
|
33
|
+
program: ts.Program,
|
|
34
|
+
fileNames: string[],
|
|
35
|
+
): ts.SourceFile[] {
|
|
36
|
+
const wanted = new Set(fileNames.map((f) => path.resolve(f)));
|
|
37
|
+
return program
|
|
38
|
+
.getSourceFiles()
|
|
39
|
+
.filter((sf) => !sf.isDeclarationFile && wanted.has(path.resolve(sf.fileName)));
|
|
40
|
+
}
|
package/src/csv-schema.ts
CHANGED
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { minimatch } from "minimatch";
|
|
4
|
-
import yaml from "js-yaml";
|
|
5
|
-
|
|
6
|
-
export interface ColumnSchema {
|
|
7
|
-
name: string;
|
|
8
|
-
type: "integer" | "float" | "boolean" | "date" | "datetime" | "string";
|
|
9
|
-
nullable: boolean;
|
|
10
|
-
unique: boolean;
|
|
11
|
-
primaryKey: boolean;
|
|
12
|
-
pii: boolean;
|
|
13
|
-
piiReason?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface TableSchema {
|
|
17
|
-
tableName: string;
|
|
18
|
-
sourceFile: string;
|
|
19
|
-
columns: ColumnSchema[];
|
|
20
|
-
rowCount: number;
|
|
21
|
-
fingerprint: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface PiiConfig {
|
|
25
|
-
patterns: string[];
|
|
26
|
-
luhn_check: boolean;
|
|
27
|
-
require_policy: boolean;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const DEFAULT_PII_PATTERNS = [
|
|
31
|
-
"*passport*",
|
|
32
|
-
"*_no",
|
|
33
|
-
"*_number",
|
|
34
|
-
"ssn",
|
|
35
|
-
"national_id",
|
|
36
|
-
"date_of_birth",
|
|
37
|
-
"dob",
|
|
38
|
-
"email",
|
|
39
|
-
"*phone*",
|
|
40
|
-
"*credit_card*",
|
|
41
|
-
"*card_number*",
|
|
42
|
-
"ip_address",
|
|
43
|
-
"latitude",
|
|
44
|
-
"longitude",
|
|
45
|
-
"*salary*",
|
|
46
|
-
"*income*",
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
function loadPiiConfig(repoRoot: string): PiiConfig {
|
|
50
|
-
const configPath = path.join(repoRoot, ".rad", "pii-patterns.yml");
|
|
51
|
-
if (!fs.existsSync(configPath)) {
|
|
52
|
-
return { patterns: DEFAULT_PII_PATTERNS, luhn_check: true, require_policy: true };
|
|
53
|
-
}
|
|
54
|
-
return yaml.load(fs.readFileSync(configPath, "utf-8")) as PiiConfig;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function isPiiColumn(name: string, config: PiiConfig): string | null {
|
|
58
|
-
const lower = name.toLowerCase();
|
|
59
|
-
for (const pattern of config.patterns) {
|
|
60
|
-
if (minimatch(lower, pattern.toLowerCase())) {
|
|
61
|
-
return `matches PII pattern "${pattern}"`;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function luhnCheck(value: string): boolean {
|
|
68
|
-
const digits = value.replace(/\D/g, "");
|
|
69
|
-
if (digits.length < 13) return false;
|
|
70
|
-
let sum = 0;
|
|
71
|
-
let alternate = false;
|
|
72
|
-
for (let i = digits.length - 1; i >= 0; i--) {
|
|
73
|
-
let n = parseInt(digits[i], 10);
|
|
74
|
-
if (alternate) {
|
|
75
|
-
n *= 2;
|
|
76
|
-
if (n > 9) n -= 9;
|
|
77
|
-
}
|
|
78
|
-
sum += n;
|
|
79
|
-
alternate = !alternate;
|
|
80
|
-
}
|
|
81
|
-
return sum % 10 === 0;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function inferType(values: string[]): ColumnSchema["type"] {
|
|
85
|
-
const nonEmpty = values.filter((v) => v.trim() !== "" && v.toLowerCase() !== "null");
|
|
86
|
-
if (nonEmpty.length === 0) return "string";
|
|
87
|
-
|
|
88
|
-
if (nonEmpty.every((v) => /^\d+$/.test(v.trim()))) return "integer";
|
|
89
|
-
if (nonEmpty.every((v) => /^-?\d+(\.\d+)?$/.test(v.trim()))) return "float";
|
|
90
|
-
if (nonEmpty.every((v) => /^(true|false|yes|no|1|0)$/i.test(v.trim()))) return "boolean";
|
|
91
|
-
if (nonEmpty.every((v) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(v.trim()))) return "datetime";
|
|
92
|
-
if (nonEmpty.every((v) => /^\d{4}-\d{2}-\d{2}$/.test(v.trim()))) return "date";
|
|
93
|
-
return "string";
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function parseCsv(content: string): { headers: string[]; rows: string[][] } {
|
|
97
|
-
const lines = content.split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
98
|
-
if (lines.length === 0) return { headers: [], rows: [] };
|
|
99
|
-
|
|
100
|
-
const splitLine = (line: string): string[] => {
|
|
101
|
-
const result: string[] = [];
|
|
102
|
-
let current = "";
|
|
103
|
-
let inQuotes = false;
|
|
104
|
-
for (let i = 0; i < line.length; i++) {
|
|
105
|
-
if (line[i] === '"') {
|
|
106
|
-
inQuotes = !inQuotes;
|
|
107
|
-
} else if (line[i] === "," && !inQuotes) {
|
|
108
|
-
result.push(current.trim());
|
|
109
|
-
current = "";
|
|
110
|
-
} else {
|
|
111
|
-
current += line[i];
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
result.push(current.trim());
|
|
115
|
-
return result;
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const headers = splitLine(lines[0]);
|
|
119
|
-
const rows = lines.slice(1).map(splitLine);
|
|
120
|
-
return { headers, rows };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function fingerprint(schema: Omit<TableSchema, "fingerprint">): string {
|
|
124
|
-
const sig = schema.columns.map((c) => `${c.name}:${c.type}:${c.pii}`).join("|");
|
|
125
|
-
// Simple hash — not crypto, just change-detection
|
|
126
|
-
let h = 0;
|
|
127
|
-
for (let i = 0; i < sig.length; i++) {
|
|
128
|
-
h = (Math.imul(31, h) + sig.charCodeAt(i)) | 0;
|
|
129
|
-
}
|
|
130
|
-
return Math.abs(h).toString(16).padStart(8, "0");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function inferSchema(
|
|
134
|
-
csvPath: string,
|
|
135
|
-
repoRoot: string,
|
|
136
|
-
): { schema: TableSchema; piiViolations: { column: string; reason: string }[] } {
|
|
137
|
-
const content = fs.readFileSync(csvPath, "utf-8");
|
|
138
|
-
const { headers, rows } = parseCsv(content);
|
|
139
|
-
const piiConfig = loadPiiConfig(repoRoot);
|
|
140
|
-
const tableName = path.basename(csvPath, ".csv");
|
|
141
|
-
|
|
142
|
-
const piiViolations: { column: string; reason: string }[] = [];
|
|
143
|
-
|
|
144
|
-
const columns: ColumnSchema[] = headers.map((name, i) => {
|
|
145
|
-
const values = rows.map((r) => r[i] ?? "");
|
|
146
|
-
const type = inferType(values);
|
|
147
|
-
const nullable = values.some((v) => v.trim() === "" || v.toLowerCase() === "null");
|
|
148
|
-
const nonEmpty = values.filter((v) => v.trim() !== "");
|
|
149
|
-
const unique = new Set(nonEmpty).size === nonEmpty.length;
|
|
150
|
-
const isPrimary = name.toLowerCase() === "id";
|
|
151
|
-
|
|
152
|
-
// PII: column name pattern match
|
|
153
|
-
let pii = false;
|
|
154
|
-
let piiReason: string | undefined;
|
|
155
|
-
const nameReason = isPiiColumn(name, piiConfig);
|
|
156
|
-
if (nameReason) {
|
|
157
|
-
pii = true;
|
|
158
|
-
piiReason = nameReason;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// PII: Luhn check on string columns that look numeric
|
|
162
|
-
if (!pii && piiConfig.luhn_check && type === "string") {
|
|
163
|
-
const luhnHits = values.filter((v) => v.trim() !== "" && luhnCheck(v));
|
|
164
|
-
if (luhnHits.length > values.length * 0.5) {
|
|
165
|
-
pii = true;
|
|
166
|
-
piiReason = "majority of values pass Luhn check (possible card numbers)";
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (pii && piiReason) {
|
|
171
|
-
piiViolations.push({ column: name, reason: piiReason });
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return { name, type, nullable, unique, primaryKey: isPrimary, pii, piiReason };
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
const base = { tableName, sourceFile: csvPath, columns, rowCount: rows.length };
|
|
178
|
-
return { schema: { ...base, fingerprint: fingerprint(base) }, piiViolations };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function discoverCsvFiles(dir: string): string[] {
|
|
182
|
-
if (!fs.existsSync(dir)) return [];
|
|
183
|
-
return fs
|
|
184
|
-
.readdirSync(dir)
|
|
185
|
-
.filter((f) => f.endsWith(".csv"))
|
|
186
|
-
.map((f) => path.join(dir, f));
|
|
187
|
-
}
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { minimatch } from "minimatch";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
|
|
6
|
+
export interface ColumnSchema {
|
|
7
|
+
name: string;
|
|
8
|
+
type: "integer" | "float" | "boolean" | "date" | "datetime" | "string";
|
|
9
|
+
nullable: boolean;
|
|
10
|
+
unique: boolean;
|
|
11
|
+
primaryKey: boolean;
|
|
12
|
+
pii: boolean;
|
|
13
|
+
piiReason?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TableSchema {
|
|
17
|
+
tableName: string;
|
|
18
|
+
sourceFile: string;
|
|
19
|
+
columns: ColumnSchema[];
|
|
20
|
+
rowCount: number;
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PiiConfig {
|
|
25
|
+
patterns: string[];
|
|
26
|
+
luhn_check: boolean;
|
|
27
|
+
require_policy: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_PII_PATTERNS = [
|
|
31
|
+
"*passport*",
|
|
32
|
+
"*_no",
|
|
33
|
+
"*_number",
|
|
34
|
+
"ssn",
|
|
35
|
+
"national_id",
|
|
36
|
+
"date_of_birth",
|
|
37
|
+
"dob",
|
|
38
|
+
"email",
|
|
39
|
+
"*phone*",
|
|
40
|
+
"*credit_card*",
|
|
41
|
+
"*card_number*",
|
|
42
|
+
"ip_address",
|
|
43
|
+
"latitude",
|
|
44
|
+
"longitude",
|
|
45
|
+
"*salary*",
|
|
46
|
+
"*income*",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function loadPiiConfig(repoRoot: string): PiiConfig {
|
|
50
|
+
const configPath = path.join(repoRoot, ".rad", "pii-patterns.yml");
|
|
51
|
+
if (!fs.existsSync(configPath)) {
|
|
52
|
+
return { patterns: DEFAULT_PII_PATTERNS, luhn_check: true, require_policy: true };
|
|
53
|
+
}
|
|
54
|
+
return yaml.load(fs.readFileSync(configPath, "utf-8")) as PiiConfig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isPiiColumn(name: string, config: PiiConfig): string | null {
|
|
58
|
+
const lower = name.toLowerCase();
|
|
59
|
+
for (const pattern of config.patterns) {
|
|
60
|
+
if (minimatch(lower, pattern.toLowerCase())) {
|
|
61
|
+
return `matches PII pattern "${pattern}"`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function luhnCheck(value: string): boolean {
|
|
68
|
+
const digits = value.replace(/\D/g, "");
|
|
69
|
+
if (digits.length < 13) return false;
|
|
70
|
+
let sum = 0;
|
|
71
|
+
let alternate = false;
|
|
72
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
73
|
+
let n = parseInt(digits[i], 10);
|
|
74
|
+
if (alternate) {
|
|
75
|
+
n *= 2;
|
|
76
|
+
if (n > 9) n -= 9;
|
|
77
|
+
}
|
|
78
|
+
sum += n;
|
|
79
|
+
alternate = !alternate;
|
|
80
|
+
}
|
|
81
|
+
return sum % 10 === 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inferType(values: string[]): ColumnSchema["type"] {
|
|
85
|
+
const nonEmpty = values.filter((v) => v.trim() !== "" && v.toLowerCase() !== "null");
|
|
86
|
+
if (nonEmpty.length === 0) return "string";
|
|
87
|
+
|
|
88
|
+
if (nonEmpty.every((v) => /^\d+$/.test(v.trim()))) return "integer";
|
|
89
|
+
if (nonEmpty.every((v) => /^-?\d+(\.\d+)?$/.test(v.trim()))) return "float";
|
|
90
|
+
if (nonEmpty.every((v) => /^(true|false|yes|no|1|0)$/i.test(v.trim()))) return "boolean";
|
|
91
|
+
if (nonEmpty.every((v) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(v.trim()))) return "datetime";
|
|
92
|
+
if (nonEmpty.every((v) => /^\d{4}-\d{2}-\d{2}$/.test(v.trim()))) return "date";
|
|
93
|
+
return "string";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseCsv(content: string): { headers: string[]; rows: string[][] } {
|
|
97
|
+
const lines = content.split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
98
|
+
if (lines.length === 0) return { headers: [], rows: [] };
|
|
99
|
+
|
|
100
|
+
const splitLine = (line: string): string[] => {
|
|
101
|
+
const result: string[] = [];
|
|
102
|
+
let current = "";
|
|
103
|
+
let inQuotes = false;
|
|
104
|
+
for (let i = 0; i < line.length; i++) {
|
|
105
|
+
if (line[i] === '"') {
|
|
106
|
+
inQuotes = !inQuotes;
|
|
107
|
+
} else if (line[i] === "," && !inQuotes) {
|
|
108
|
+
result.push(current.trim());
|
|
109
|
+
current = "";
|
|
110
|
+
} else {
|
|
111
|
+
current += line[i];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
result.push(current.trim());
|
|
115
|
+
return result;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const headers = splitLine(lines[0]);
|
|
119
|
+
const rows = lines.slice(1).map(splitLine);
|
|
120
|
+
return { headers, rows };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fingerprint(schema: Omit<TableSchema, "fingerprint">): string {
|
|
124
|
+
const sig = schema.columns.map((c) => `${c.name}:${c.type}:${c.pii}`).join("|");
|
|
125
|
+
// Simple hash — not crypto, just change-detection
|
|
126
|
+
let h = 0;
|
|
127
|
+
for (let i = 0; i < sig.length; i++) {
|
|
128
|
+
h = (Math.imul(31, h) + sig.charCodeAt(i)) | 0;
|
|
129
|
+
}
|
|
130
|
+
return Math.abs(h).toString(16).padStart(8, "0");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function inferSchema(
|
|
134
|
+
csvPath: string,
|
|
135
|
+
repoRoot: string,
|
|
136
|
+
): { schema: TableSchema; piiViolations: { column: string; reason: string }[] } {
|
|
137
|
+
const content = fs.readFileSync(csvPath, "utf-8");
|
|
138
|
+
const { headers, rows } = parseCsv(content);
|
|
139
|
+
const piiConfig = loadPiiConfig(repoRoot);
|
|
140
|
+
const tableName = path.basename(csvPath, ".csv");
|
|
141
|
+
|
|
142
|
+
const piiViolations: { column: string; reason: string }[] = [];
|
|
143
|
+
|
|
144
|
+
const columns: ColumnSchema[] = headers.map((name, i) => {
|
|
145
|
+
const values = rows.map((r) => r[i] ?? "");
|
|
146
|
+
const type = inferType(values);
|
|
147
|
+
const nullable = values.some((v) => v.trim() === "" || v.toLowerCase() === "null");
|
|
148
|
+
const nonEmpty = values.filter((v) => v.trim() !== "");
|
|
149
|
+
const unique = new Set(nonEmpty).size === nonEmpty.length;
|
|
150
|
+
const isPrimary = name.toLowerCase() === "id";
|
|
151
|
+
|
|
152
|
+
// PII: column name pattern match
|
|
153
|
+
let pii = false;
|
|
154
|
+
let piiReason: string | undefined;
|
|
155
|
+
const nameReason = isPiiColumn(name, piiConfig);
|
|
156
|
+
if (nameReason) {
|
|
157
|
+
pii = true;
|
|
158
|
+
piiReason = nameReason;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// PII: Luhn check on string columns that look numeric
|
|
162
|
+
if (!pii && piiConfig.luhn_check && type === "string") {
|
|
163
|
+
const luhnHits = values.filter((v) => v.trim() !== "" && luhnCheck(v));
|
|
164
|
+
if (luhnHits.length > values.length * 0.5) {
|
|
165
|
+
pii = true;
|
|
166
|
+
piiReason = "majority of values pass Luhn check (possible card numbers)";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (pii && piiReason) {
|
|
171
|
+
piiViolations.push({ column: name, reason: piiReason });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { name, type, nullable, unique, primaryKey: isPrimary, pii, piiReason };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const base = { tableName, sourceFile: csvPath, columns, rowCount: rows.length };
|
|
178
|
+
return { schema: { ...base, fingerprint: fingerprint(base) }, piiViolations };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function discoverCsvFiles(dir: string): string[] {
|
|
182
|
+
if (!fs.existsSync(dir)) return [];
|
|
183
|
+
return fs
|
|
184
|
+
.readdirSync(dir)
|
|
185
|
+
.filter((f) => f.endsWith(".csv"))
|
|
186
|
+
.map((f) => path.join(dir, f));
|
|
187
|
+
}
|