@loomwork/classify 0.0.0-stage → 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +73 -2
  4. package/dist/classify.d.ts +47 -0
  5. package/dist/classify.d.ts.map +1 -0
  6. package/dist/classify.js +147 -0
  7. package/dist/classify.js.map +1 -0
  8. package/dist/esm/classify.js +135 -0
  9. package/dist/esm/classify.js.map +1 -0
  10. package/dist/esm/expand.js +87 -0
  11. package/dist/esm/expand.js.map +1 -0
  12. package/dist/esm/external.js +33 -0
  13. package/dist/esm/external.js.map +1 -0
  14. package/dist/esm/index.js +7 -0
  15. package/dist/esm/index.js.map +1 -0
  16. package/dist/esm/redact.js +56 -0
  17. package/dist/esm/redact.js.map +1 -0
  18. package/dist/esm/taint.js +142 -0
  19. package/dist/esm/taint.js.map +1 -0
  20. package/dist/esm/unreachable.js +126 -0
  21. package/dist/esm/unreachable.js.map +1 -0
  22. package/dist/expand.d.ts +16 -0
  23. package/dist/expand.d.ts.map +1 -0
  24. package/dist/expand.js +91 -0
  25. package/dist/expand.js.map +1 -0
  26. package/dist/external.d.ts +15 -0
  27. package/dist/external.d.ts.map +1 -0
  28. package/dist/external.js +38 -0
  29. package/dist/external.js.map +1 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +38 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/redact.d.ts +4 -0
  35. package/dist/redact.d.ts.map +1 -0
  36. package/dist/redact.js +60 -0
  37. package/dist/redact.js.map +1 -0
  38. package/dist/taint.d.ts +85 -0
  39. package/dist/taint.d.ts.map +1 -0
  40. package/dist/taint.js +152 -0
  41. package/dist/taint.js.map +1 -0
  42. package/dist/unreachable.d.ts +20 -0
  43. package/dist/unreachable.d.ts.map +1 -0
  44. package/dist/unreachable.js +129 -0
  45. package/dist/unreachable.js.map +1 -0
  46. package/package.json +55 -3
  47. package/src/classify.ts +175 -0
  48. package/src/expand.ts +97 -0
  49. package/src/external.ts +40 -0
  50. package/src/index.ts +31 -0
  51. package/src/redact.ts +59 -0
  52. package/src/taint.ts +189 -0
  53. package/src/unreachable.ts +164 -0
@@ -0,0 +1,164 @@
1
+ import type { z } from "zod";
2
+ import { type DataClass, classOf } from "./classify";
3
+
4
+ // ── Marks the dot-path sinks cannot reach ─────────────────────────────────────
5
+ //
6
+ // `collectMarkedPaths` walks objects and — since #260 option 2 — arrays, via a `[]` crossing
7
+ // segment. Everything else it simply does not descend: a record's values, a union's branches,
8
+ // a tuple's members, an intersection's sides. A mark placed under one of those collects no
9
+ // path, so `maskPaths` never masks it, the crypto field walker never encrypts it, and the
10
+ // content externalizer never externalizes it. The developer marked the field; the mark does
11
+ // nothing, and nothing says so. This finds those marks so a pre-flight check can.
12
+ //
13
+ // It mirrors the walker it audits — same transparent wrappers, same "a marked object is a
14
+ // unit, don't descend" rule, same array traversal — so the two agree on what IS reachable.
15
+ // Divergence would produce false positives on fields that are in fact masked, which is worse
16
+ // than the gap being reported.
17
+
18
+ /** A classification mark that no dot-path sink can address. */
19
+ export interface UnreachableMark {
20
+ /**
21
+ * Dot-path to the mark, with `[]` per array crossing and `<Type>` where an untraversable
22
+ * container sits — e.g. `"byId.<record>.ssn"`. Not an addressable path; a location.
23
+ */
24
+ readonly path: string;
25
+ readonly dataClass: DataClass;
26
+ /** The container that blocks it, lowercased without the `Zod` prefix (`record`, `union`, …). */
27
+ readonly container: string;
28
+ }
29
+
30
+ /** Unwrap optional/nullable/default/effects, as the classification walker does. */
31
+ function chain(schema: z.ZodTypeAny): { nodes: z.ZodTypeAny[]; inner: z.ZodTypeAny } {
32
+ const nodes: z.ZodTypeAny[] = [];
33
+ let cur: z.ZodTypeAny = schema;
34
+ for (let i = 0; i < 100; i++) {
35
+ nodes.push(cur);
36
+ const def = (cur as { _def?: Record<string, unknown> })._def;
37
+ const typeName = def?.typeName as string | undefined;
38
+ let next: unknown;
39
+ if (typeName === "ZodOptional" || typeName === "ZodNullable" || typeName === "ZodDefault") {
40
+ next = def?.innerType;
41
+ } else if (typeName === "ZodEffects") {
42
+ next = def?.schema;
43
+ } else {
44
+ break;
45
+ }
46
+ if (next === undefined) break;
47
+ cur = next as z.ZodTypeAny;
48
+ }
49
+ return { nodes, inner: cur };
50
+ }
51
+
52
+ function typeNameOf(node: z.ZodTypeAny): string | undefined {
53
+ return (node as { _def?: { typeName?: string } })._def?.typeName;
54
+ }
55
+
56
+ function objectShape(node: z.ZodTypeAny): Record<string, z.ZodTypeAny> | undefined {
57
+ const def = (node as { _def?: { typeName?: string; shape?: () => unknown } })._def;
58
+ if (def?.typeName !== "ZodObject" || typeof def.shape !== "function") return undefined;
59
+ return def.shape() as Record<string, z.ZodTypeAny>;
60
+ }
61
+
62
+ function arrayElement(node: z.ZodTypeAny): z.ZodTypeAny | undefined {
63
+ const def = (node as { _def?: { typeName?: string; type?: unknown } })._def;
64
+ if (def?.typeName !== "ZodArray") return undefined;
65
+ return def.type as z.ZodTypeAny | undefined;
66
+ }
67
+
68
+ /**
69
+ * Schemas that hold inner schemas the classification walker never descends into. Returns the
70
+ * children to search for stranded marks, plus the label for the location string.
71
+ *
72
+ * `lazy` is deliberately absent: descending a recursive schema to hunt for marks risks
73
+ * non-termination, and the payoff is small.
74
+ */
75
+ function opaqueChildren(
76
+ node: z.ZodTypeAny
77
+ ): { label: string; children: z.ZodTypeAny[] } | undefined {
78
+ const def = (node as { _def?: Record<string, unknown> })._def;
79
+ const typeName = typeNameOf(node);
80
+ switch (typeName) {
81
+ case "ZodRecord":
82
+ case "ZodMap":
83
+ case "ZodSet":
84
+ return {
85
+ label: typeName.slice(3).toLowerCase(),
86
+ children: [def?.valueType as z.ZodTypeAny].filter(Boolean),
87
+ };
88
+ case "ZodUnion":
89
+ case "ZodDiscriminatedUnion": {
90
+ const options = def?.options;
91
+ const list = Array.isArray(options)
92
+ ? options
93
+ : [...((options as Map<unknown, unknown>)?.values?.() ?? [])];
94
+ return { label: "union", children: list as z.ZodTypeAny[] };
95
+ }
96
+ case "ZodTuple":
97
+ return { label: "tuple", children: (def?.items as z.ZodTypeAny[]) ?? [] };
98
+ case "ZodIntersection":
99
+ return {
100
+ label: "intersection",
101
+ children: [def?.left as z.ZodTypeAny, def?.right as z.ZodTypeAny].filter(Boolean),
102
+ };
103
+ default:
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ function effectiveClass(schema: z.ZodTypeAny): DataClass | undefined {
109
+ for (const node of chain(schema).nodes) {
110
+ const mark = classOf(node);
111
+ if (mark !== undefined) return mark;
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ /**
117
+ * Every classification mark stranded under a container the dot-path sinks cannot address.
118
+ *
119
+ * Returns `[]` for marks on objects and arrays — both are traversable, so those marks work.
120
+ */
121
+ export function collectUnreachableMarks(schema: z.ZodTypeAny, prefix = ""): UnreachableMark[] {
122
+ return walk(schema, prefix, undefined);
123
+ }
124
+
125
+ function walk(
126
+ schema: z.ZodTypeAny,
127
+ path: string,
128
+ blockedBy: string | undefined
129
+ ): UnreachableMark[] {
130
+ const mark = effectiveClass(schema);
131
+ if (mark !== undefined && path !== "") {
132
+ // A mark terminates the walk exactly as it does in `collectMarkedPaths` — a marked object
133
+ // is a unit — so a mark nested inside a marked one is not double-reported.
134
+ return blockedBy === undefined ? [] : [{ path, dataClass: mark, container: blockedBy }];
135
+ }
136
+
137
+ const inner = chain(schema).inner;
138
+
139
+ const element = arrayElement(inner);
140
+ if (element !== undefined) return walk(element, `${path}[]`, blockedBy);
141
+
142
+ const shape = objectShape(inner);
143
+ if (shape !== undefined) {
144
+ const out: UnreachableMark[] = [];
145
+ for (const [key, child] of Object.entries(shape)) {
146
+ out.push(...walk(child, path === "" ? key : `${path}.${key}`, blockedBy));
147
+ }
148
+ return out;
149
+ }
150
+
151
+ const opaque = opaqueChildren(inner);
152
+ if (opaque !== undefined) {
153
+ const out: UnreachableMark[] = [];
154
+ // Everything below here is unreachable — but keep the OUTERMOST blocker, since that is
155
+ // the one the developer has to restructure.
156
+ const blocker = blockedBy ?? opaque.label;
157
+ for (const child of opaque.children) {
158
+ out.push(...walk(child, `${path}.<${opaque.label}>`, blocker));
159
+ }
160
+ return out;
161
+ }
162
+
163
+ return [];
164
+ }