|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #15494 — the object probe plane must never convert a rule CRASH into |
| 5 | + * "nothing wrong". |
| 6 | + * |
| 7 | + * ## What this pins, and the state it replaces |
| 8 | + * |
| 9 | + * `runBuildProbes`' object plane re-runs `validateObjectFieldRefs` over each |
| 10 | + * published object's ACTIVE body and counts it in `checked.objects`. The call |
| 11 | + * was wrapped in `catch { findings = [] }`, so a rule that threw produced the |
| 12 | + * byte-identical receipt a genuinely clean object produces: the count went up, |
| 13 | + * the issue list stayed empty. That is the one reading this plane exists to |
| 14 | + * make impossible — it was added (#15254) precisely because a count that |
| 15 | + * cannot go up is indistinguishable from a plane that found nothing wrong, and |
| 16 | + * the silent catch reinstated the same ambiguity one layer in. |
| 17 | + * |
| 18 | + * The crash that motivated the card is a null entry in `stack.objects` |
| 19 | + * dereferenced by the shared `indexObjectGraph` seam, repaired in |
| 20 | + * `@objectstack/lint` in the same change. This file pins the OTHER half, which |
| 21 | + * outlives that bug: whatever the next rule failure is, the receipt says the |
| 22 | + * object was not checked, and says why. |
| 23 | + * |
| 24 | + * ## Why the rule is mocked rather than provoked |
| 25 | + * |
| 26 | + * With the seam repaired there is no longer a published body that makes the |
| 27 | + * real rule throw — which is the point of the repair. Reaching the branch |
| 28 | + * therefore means substituting a throwing rule, and `build-probes.ts` imports |
| 29 | + * `@objectstack/lint` LAZILY (`await import`) at call time, so `vi.doMock` |
| 30 | + * plus a fresh module graph per test is exact: nothing else in the file, and |
| 31 | + * no other suite, sees a mocked lint package. |
| 32 | + * |
| 33 | + * ## The `adr0112-ok:` marks below |
| 34 | + * |
| 35 | + * `object_field_ref_rule_failed` is a build-probe diagnostics code shipped |
| 36 | + * inside a 200 receipt (ADR-0112 D6c), not an `error.code` from the closed |
| 37 | + * catalog — the same vocabulary as every other probe code, for which |
| 38 | + * `check:error-code-casing` exempts `build-probes.ts` and |
| 39 | + * `packages/objectql/src/build-probes.test.ts` whole. The marks here are the |
| 40 | + * narrower per-literal spelling of that same exemption, and they are written |
| 41 | + * on the literal's OWN line deliberately: a multi-line comment above the |
| 42 | + * literal was measured to move it out of the gate's recognition window |
| 43 | + * entirely, which reads as a suppression while actually being a blind spot. |
| 44 | + */ |
| 45 | + |
| 46 | +import { describe, expect, it, vi, afterEach } from 'vitest'; |
| 47 | +import type { ProbeEngine } from './build-probes.js'; |
| 48 | + |
| 49 | +const OBJECT_BODY = { |
| 50 | + name: 'crm_lead', |
| 51 | + fields: { name: { type: 'text', label: 'Name' } }, |
| 52 | + highlightFields: ['name'], |
| 53 | +}; |
| 54 | + |
| 55 | +const getItem = async (type: string, name: string) => |
| 56 | + type === 'object' && name === 'crm_lead' ? OBJECT_BODY : undefined; |
| 57 | + |
| 58 | +/** |
| 59 | + * The probes' single engine read. The object plane never calls it, but the |
| 60 | + * double still honours the caller's `limit` by presence rather than ignoring |
| 61 | + * it — a `find` double that answers more rows than it was asked for is how a |
| 62 | + * limit regression rides through a green suite (`check:objectql-double-limit`). |
| 63 | + */ |
| 64 | +const engine: ProbeEngine = { |
| 65 | + find: async (_object: string, query: unknown) => { |
| 66 | + const rows = [{ id: 'r1' }, { id: 'r2' }]; |
| 67 | + const limit = (query as { limit?: unknown } | undefined)?.limit; |
| 68 | + return typeof limit === 'number' ? rows.slice(0, limit) : rows; |
| 69 | + }, |
| 70 | +}; |
| 71 | + |
| 72 | +afterEach(() => { |
| 73 | + vi.doUnmock('@objectstack/lint'); |
| 74 | + vi.resetModules(); |
| 75 | +}); |
| 76 | + |
| 77 | +async function probeWith(validateObjectFieldRefs: (stack: Record<string, unknown>) => unknown) { |
| 78 | + vi.resetModules(); |
| 79 | + vi.doMock('@objectstack/lint', () => ({ validateObjectFieldRefs })); |
| 80 | + const { runBuildProbes } = await import('./build-probes.js'); |
| 81 | + return runBuildProbes({ |
| 82 | + engine, |
| 83 | + getItem, |
| 84 | + published: [{ type: 'object', name: 'crm_lead' }], |
| 85 | + }); |
| 86 | +} |
| 87 | + |
| 88 | +describe('runBuildProbes — a throwing object rule is reported, never swallowed', () => { |
| 89 | + it('surfaces the crash as a runtime-layer error naming the object and the thrown message', async () => { |
| 90 | + const report = await probeWith(() => { |
| 91 | + throw new TypeError("Cannot read properties of null (reading 'name')"); |
| 92 | + }); |
| 93 | + |
| 94 | + // The count still goes up — the object WAS reached; what failed is the |
| 95 | + // judgement. Reporting one without the other is the ambiguity again. |
| 96 | + expect(report.checked.objects).toBe(1); |
| 97 | + expect(report.issues).toHaveLength(1); |
| 98 | + expect(report.issues[0]).toMatchObject({ |
| 99 | + layer: 'runtime', |
| 100 | + severity: 'error', |
| 101 | + code: 'object_field_ref_rule_failed', // adr0112-ok: D6c build-probe diagnostics code |
| 102 | + artifact: { type: 'object', name: 'crm_lead' }, |
| 103 | + }); |
| 104 | + // The thrown message rides the receipt: without it the report says a |
| 105 | + // rule failed and gives nobody a way to find out which defect. |
| 106 | + expect(report.issues[0].message).toContain("Cannot read properties of null (reading 'name')"); |
| 107 | + expect(report.issues[0].message).toContain('crm_lead'); |
| 108 | + // ⛔ The one reading that must be impossible. |
| 109 | + expect(report.issues, 'a crash must not read as zero findings').not.toEqual([]); |
| 110 | + }); |
| 111 | + |
| 112 | + it('reports a non-Error throw too — the message is whatever was thrown', async () => { |
| 113 | + const report = await probeWith(() => { |
| 114 | + throw 'rule exploded'; |
| 115 | + }); |
| 116 | + expect(report.issues[0]).toMatchObject({ code: 'object_field_ref_rule_failed' }); // adr0112-ok: D6c build-probe diagnostics code |
| 117 | + expect(report.issues[0].message).toContain('rule exploded'); |
| 118 | + }); |
| 119 | + |
| 120 | + it('a clean rule still produces the clean receipt — the contrast case', async () => { |
| 121 | + // Without this the test above would pass just as well against a probe |
| 122 | + // that reported a failure for every object. |
| 123 | + const report = await probeWith(() => []); |
| 124 | + expect(report.checked.objects).toBe(1); |
| 125 | + expect(report.issues).toEqual([]); |
| 126 | + }); |
| 127 | + |
| 128 | + it('a rule that finds a dangling reference still reports THAT, not a failure', async () => { |
| 129 | + const report = await probeWith(() => [ |
| 130 | + { path: 'objects.crm_lead.highlightFields[0]', message: 'no such field', hint: 'add it' }, |
| 131 | + ]); |
| 132 | + expect(report.issues).toHaveLength(1); |
| 133 | + expect(report.issues[0].code).toBe('object_field_ref_unknown'); |
| 134 | + }); |
| 135 | +}); |
0 commit comments