|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * `ObjectValidationEngine` stays unwired — the mechanism behind #3110. |
| 11 | + * |
| 12 | + * The decision was: validation rules are ENFORCEMENT, enforcement is |
| 13 | + * single-implementation on the server, and objectui renders the server's |
| 14 | + * rejection rather than predicting it. `evaluator/fieldRules.ts` draws the same |
| 15 | + * line for the presentation predicates it DOES evaluate client-side — it |
| 16 | + * delegates to the canonical `ExpressionEngine` "rather than re-implementing a |
| 17 | + * parallel evaluator" (ADR-0036). This engine is that parallel evaluator. |
| 18 | + * |
| 19 | + * #3103 converged it onto the server rule-for-rule and still left a known |
| 20 | + * divergence (ADR-0113's legacy-violation exemption), which is the whole |
| 21 | + * argument: mirroring cross-repo behaviour is structurally unreliable. |
| 22 | + * |
| 23 | + * Why a test and not just the `@deprecated` tag: the thing this replaced was a |
| 24 | + * doc comment claiming spec canonicity that had been false for fifteen majors |
| 25 | + * (#3103). Shipping the successor decision as another comment would repeat the |
| 26 | + * mistake one level up — a rule that is not a check is not a rule (#3017, |
| 27 | + * objectstack#4115). So the ban is compiled here: wire the engine into a |
| 28 | + * production module and this file names the file, the line, and the issue to |
| 29 | + * reverse first. |
| 30 | + * |
| 31 | + * This is deliberately a REACHABILITY ban, not a mention ban. Nothing stops a |
| 32 | + * host application from importing the deprecated engine — it is still exported, |
| 33 | + * still functional. What is banned is objectui itself growing a client-side |
| 34 | + * enforcement path again. |
| 35 | + */ |
| 36 | + |
| 37 | +import { describe, it, expect } from 'vitest'; |
| 38 | +import { readdirSync, readFileSync, statSync } from 'node:fs'; |
| 39 | +import { join, resolve, relative } from 'node:path'; |
| 40 | + |
| 41 | +/** `packages/` — three levels up from `packages/core/src/validation/__tests__`. */ |
| 42 | +const PACKAGES_DIR = resolve(__dirname, '../../../..'); |
| 43 | + |
| 44 | +/** |
| 45 | + * The engine's own module, its barrels, and its tests. A barrel re-export is not |
| 46 | + * a wiring: it publishes the deprecated API for host applications, which is |
| 47 | + * exactly what "deprecated but not removed" means. |
| 48 | + */ |
| 49 | +const ALLOWED = [ |
| 50 | + 'core/src/validation/validators/object-validation-engine.ts', |
| 51 | + 'core/src/validation/validators/index.ts', |
| 52 | + 'core/src/validation/index.ts', |
| 53 | +]; |
| 54 | + |
| 55 | +/** The engine's module path, and the entry points a caller would name. */ |
| 56 | +const ENGINE_REFERENCE = |
| 57 | + /object-validation-engine|ObjectValidationEngine|defaultObjectValidationEngine/; |
| 58 | + |
| 59 | +/** |
| 60 | + * Strip comments and string bodies before looking for a reference. |
| 61 | + * |
| 62 | + * Prose is not a wiring: `@object-ui/types` names the engine in a doc comment |
| 63 | + * explaining which defaults it applies, and that must not read as a production |
| 64 | + * import. Scanning the CODE (rather than the import statements alone) still |
| 65 | + * catches the indirect route — `import * as core` followed by |
| 66 | + * `core.ObjectValidationEngine` — which an import-list regex would miss. |
| 67 | + * |
| 68 | + * String bodies go too, so a URL or an error message quoting the name is prose |
| 69 | + * as well. Template-literal substitutions are kept, since that is real code. |
| 70 | + */ |
| 71 | +function stripCommentsAndStrings(src: string): string { |
| 72 | + let out = ''; |
| 73 | + let i = 0; |
| 74 | + while (i < src.length) { |
| 75 | + const two = src.slice(i, i + 2); |
| 76 | + if (two === '//') { |
| 77 | + while (i < src.length && src[i] !== '\n') i++; |
| 78 | + continue; |
| 79 | + } |
| 80 | + if (two === '/*') { |
| 81 | + i += 2; |
| 82 | + while (i < src.length && src.slice(i, i + 2) !== '*/') i++; |
| 83 | + i += 2; |
| 84 | + continue; |
| 85 | + } |
| 86 | + const ch = src[i]; |
| 87 | + if (ch === '"' || ch === "'" || ch === '`') { |
| 88 | + const quote = ch; |
| 89 | + i++; |
| 90 | + while (i < src.length) { |
| 91 | + if (src[i] === '\\') { |
| 92 | + i += 2; |
| 93 | + continue; |
| 94 | + } |
| 95 | + // Keep `${…}` substitutions — they are code, not prose. |
| 96 | + if (quote === '`' && src.slice(i, i + 2) === '${') { |
| 97 | + let depth = 1; |
| 98 | + i += 2; |
| 99 | + const start = i; |
| 100 | + while (i < src.length && depth > 0) { |
| 101 | + if (src[i] === '{') depth++; |
| 102 | + else if (src[i] === '}') depth--; |
| 103 | + if (depth > 0) i++; |
| 104 | + } |
| 105 | + out += src.slice(start, i); |
| 106 | + i++; |
| 107 | + continue; |
| 108 | + } |
| 109 | + if (src[i] === quote) break; |
| 110 | + i++; |
| 111 | + } |
| 112 | + i++; |
| 113 | + // A module specifier is a string, so preserve the ones that name the |
| 114 | + // engine's module — an `import … from '…/object-validation-engine'` must |
| 115 | + // still be caught. |
| 116 | + continue; |
| 117 | + } |
| 118 | + out += ch; |
| 119 | + i++; |
| 120 | + } |
| 121 | + return out; |
| 122 | +} |
| 123 | + |
| 124 | +/** Module specifiers are strings, so they are checked separately. */ |
| 125 | +const IMPORT_SPECIFIER = /(?:from|import|require)\s*\(?\s*['"]([^'"]+)['"]/g; |
| 126 | + |
| 127 | +function referencesEngine(src: string): boolean { |
| 128 | + if (ENGINE_REFERENCE.test(stripCommentsAndStrings(src))) return true; |
| 129 | + for (const [, specifier] of src.matchAll(IMPORT_SPECIFIER)) { |
| 130 | + if (specifier.includes('object-validation-engine')) return true; |
| 131 | + } |
| 132 | + return false; |
| 133 | +} |
| 134 | + |
| 135 | +function sourceFiles(): string[] { |
| 136 | + const out: string[] = []; |
| 137 | + const walk = (dir: string) => { |
| 138 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 139 | + const full = join(dir, entry.name); |
| 140 | + if (entry.isDirectory()) { |
| 141 | + // Tests are not a production path, and `dist` is build output. |
| 142 | + if (['node_modules', 'dist', '__tests__'].includes(entry.name)) continue; |
| 143 | + walk(full); |
| 144 | + continue; |
| 145 | + } |
| 146 | + if (!/\.tsx?$/.test(entry.name) || /\.d\.ts$/.test(entry.name)) continue; |
| 147 | + if (/\.(test|stories)\.tsx?$/.test(entry.name)) continue; |
| 148 | + out.push(full); |
| 149 | + } |
| 150 | + }; |
| 151 | + |
| 152 | + for (const pkg of readdirSync(PACKAGES_DIR)) { |
| 153 | + const src = join(PACKAGES_DIR, pkg, 'src'); |
| 154 | + try { |
| 155 | + if (!statSync(src).isDirectory()) continue; |
| 156 | + } catch { |
| 157 | + continue; |
| 158 | + } |
| 159 | + walk(src); |
| 160 | + } |
| 161 | + return out; |
| 162 | +} |
| 163 | + |
| 164 | +describe('the deprecated ObjectValidationEngine stays out of production paths (#3110)', () => { |
| 165 | + it('scans a plausible source tree — so a passing result means something', () => { |
| 166 | + // Without this, a broken path silently scans zero files and the ban above |
| 167 | + // reports success forever. That failure mode is the one objectstack#4115 |
| 168 | + // was filed about, so the guard guards itself. |
| 169 | + const files = sourceFiles(); |
| 170 | + expect(files.length).toBeGreaterThan(500); |
| 171 | + expect( |
| 172 | + files.some((f) => f.endsWith('core/src/validation/validators/object-validation-engine.ts')) |
| 173 | + ).toBe(true); |
| 174 | + }); |
| 175 | + |
| 176 | + it('is referenced only by its own module and the barrels that publish it', () => { |
| 177 | + const offenders = sourceFiles() |
| 178 | + .filter((file) => referencesEngine(readFileSync(file, 'utf8'))) |
| 179 | + .map((file) => relative(PACKAGES_DIR, file)) |
| 180 | + .filter((rel) => !ALLOWED.includes(rel)) |
| 181 | + .sort(); |
| 182 | + |
| 183 | + expect( |
| 184 | + offenders, |
| 185 | + offenders.length === 0 |
| 186 | + ? '' |
| 187 | + : `A production module now references the deprecated ObjectValidationEngine:\n` + |
| 188 | + offenders.map((o) => ` - packages/${o}`).join('\n') + |
| 189 | + `\n\n` + |
| 190 | + `#3110 decided objectui does NOT run object-level validation rules on the\n` + |
| 191 | + `client: enforcement is single-implementation on the server, and mirroring\n` + |
| 192 | + `it drifts (see ADR-0113's legacy-violation exemption, still divergent).\n\n` + |
| 193 | + `For pre-submit feedback, use a validate-only (dry-run) write instead — it\n` + |
| 194 | + `also covers \`unique\` and \`json_schema\`, which a client cannot check.\n\n` + |
| 195 | + `If you are deliberately reversing the decision (e.g. offline writes),\n` + |
| 196 | + `reverse it in #3110 first, land the spec conformance fixtures it names,\n` + |
| 197 | + `then update ALLOWED here with the reason.` |
| 198 | + ).toEqual([]); |
| 199 | + }); |
| 200 | +}); |
0 commit comments