Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/control-bytes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Control Bytes

# Why this is its own workflow instead of a job in `ci.yml` or `lint.yml`: both
# of those list `'**/*.md'`, `content/**`, `docs/**` and `.changeset/**` under
# `paths-ignore`, and GitHub has no per-job path filter. A raw control byte lands
# in markdown exactly as easily as in TypeScript — objectstack#4890 was a NUL in
# a `.claude/` skill file, emitted by the very PR that was writing the rule
# against it — so a gate that cannot see a markdown-only PR rebuilds the hole it
# exists to close. `changeset-guard.yml` sits in this repo for the same reason
# and says so in its own header.
#
# Hence: no `paths` and no `paths-ignore` here, deliberately.
# `scripts/__tests__/check-control-bytes.test.ts` fails if either is ever added.
#
# It needs no install and no build — a checkout plus one `node` call over
# `git ls-files`, a few seconds — so keep it that way if you add checks to it.

on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
workflow_dispatch:

concurrency:
group: control-bytes-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
control-bytes:
name: Control Byte Scan
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'

# A single raw U+0000 makes grep and ripgrep classify the whole file as
# binary and print no matching lines at all, so the file drops out of code
# search and out of every grep-based lint — with no error to say so. git
# will not warn either: it decides binary-ness from the first 8000 bytes
# only. Reads `git ls-files`, so no install is required.
- name: Scan tracked text files for raw control bytes
run: node scripts/check-control-bytes.mjs
49 changes: 49 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ one has its own section below.
| `ci.yml` | CI | Push / PR to `main`, `develop` | **Yes** — 6 of its 7 jobs run on PRs |
| `lint.yml` | Lint | Push / PR to `main`, `develop`; manual | **Yes** — ESLint **errors** only |
| `changeset-guard.yml` | Changeset Bump Policy | PR / push touching `.changeset/**` | **Yes** |
| `control-bytes.yml` | Control Byte Scan | Push / PR to `main`, `develop` — **no path filter**; manual | **Yes** |
| `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget |
| `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane, `continue-on-error` |
| `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No |
Expand Down Expand Up @@ -97,6 +98,54 @@ the rules `eslint.config.js` sets to `error` — including the custom `object-ui
this workflow was `workflow_dispatch`-only, so every one of those `error` ratchets was inert: each
was written specifically to fail CI, and nothing ran them.

## Control Bytes (`control-bytes.yml`)

**Triggers:** Push and PR to `main`/`develop`, plus manual dispatch — with **no path filter at
all**, which is the point of the workflow. It appears in the checks list as **Control Byte Scan**.

Runs `scripts/check-control-bytes.mjs`, which reads `git ls-files` and rejects raw control
characters in every tracked **text** file: the C0 range apart from tab, line feed and carriage
return, plus U+007F. No install, no build — a checkout and one `node` call.

**Why it blocks a merge.** A single raw U+0000 makes grep and ripgrep classify the *entire* file
as binary: they print `binary file matches` and no matching line, so the file silently drops out
of code search and out of every grep-based lint. Nothing else catches it — git decides
binary-ness from the first 8000 bytes only, so a control byte past that offset keeps diffing as
ordinary text, and review cannot see a character that renders as nothing. objectui had no such
guard until objectstack#5425, by which time five files had accumulated the defect.

The two byte classes carry different harms and the report says which:

| Byte | Harm | Measured behaviour |
|---|---|---|
| U+0000 | Code-search outage | GNU grep 3.11 and ripgrep 14 both refuse to print matching lines |
| Every other control byte | Invisible, unreviewable literal | Both tools print the line normally |

Covering only U+0000 would reproduce a known miss: objectstack#5140 shipped a NUL *and* a U+0001
fourteen bytes away, and the NUL-only scanner reported OK on the second one (objectstack#5157).

**Why it is a separate workflow.** `ci.yml` and `lint.yml` both list `'**/*.md'`, `content/**`,
`docs/**` and `.changeset/**` under `paths-ignore`, and GitHub has no per-job path filter. Markdown
is exactly the carrier the worst instance of this bug used — objectstack#4890 was a raw NUL in a
`.claude/` skill file, emitted by the PR that was writing the rule forbidding it, leaving the agent
instructions unfindable by `grep -r` with no signal that anything was missing. A path-filtered gate
could not have seen that PR. `scripts/__tests__/check-control-bytes.test.ts` fails if a `paths` or
`paths-ignore` key is ever added here.

**If it fails:** write the escape sequence (backslash, lowercase `u`, four zeroes) instead of the
byte — the resulting string is byte-identical at runtime. Better still, if the byte was only ever
"a character the data cannot contain" (a join/split separator, a sentinel), use something a reader
can verify: a newline, a comma, or `JSON.stringify`, which needs no impossible character at all.
When writing *about* these bytes in prose or in a tool payload, name them as `U+0000` — a backslash
escape typed into an agent's tool payload gets decoded into the real byte before it reaches disk,
which is how two of the five incidents in this family happened.

**Known pre-existing offenders.** `KNOWN_OFFENDERS` in the script baselines the files that already
carried a control byte when the gate landed, so it could be switched on as a ratchet. It is not a
skip-list: the scan fails on an entry whose file has been cleaned or deleted, so a fix that forgets
to remove its entry is as red as a new offender. Entries carry the issue tracking their removal
(objectstack#5450) and the map is expected to reach empty and stay there.

## Performance Budget (`performance-budget.yml`)

**Triggers:** Push and PR when changes touch `packages/`, `apps/console/`, or `pnpm-lock.yaml`.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"type-check": "turbo run type-check",
"type-check:coverage": "node scripts/check-type-check-coverage.mjs",
"check:spec-symbols": "node scripts/check-spec-symbol-derivation.mjs",
"check:control-bytes": "node scripts/check-control-bytes.mjs",
"cli": "node packages/cli/dist/cli.js",
"objectui": "node packages/cli/dist/cli.js",
"create-plugin": "node packages/create-plugin/dist/index.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { renderHook, waitFor, cleanup } from '@testing-library/react';

/**
* objectstack#5425. `useDatasetFieldCatalog` collapses its `include: string[]`
* prop into one scalar so it can sit in a `React.useEffect` dependency array,
* then expands it again inside the effect. That encode/decode pair used to be
* `join` / `split` on a raw U+0000 byte — a byte that made this entire file
* binary to grep, so nothing in it was findable by content search.
*
* The pair is now `JSON.stringify` / `JSON.parse`, which needs no "character the
* data cannot contain" at all. These tests pin the behaviour that round-trip is
* responsible for, because it is the only thing the swap could have broken:
* which paths get walked, and when the effect re-runs.
*
* No control byte appears in this file, and none is needed to test the fix —
* that is rather the point of the change.
*/

/**
* The client identity must be STABLE across renders. The real
* `useMetadataClient` memoizes with `useMemo`, and the effect under test lists
* `client` in its dependency array — so a mock that returns a fresh object per
* render re-runs the effect on every render, whose `setState` triggers the next
* render. That is an unbounded loop that OOMs the worker rather than failing an
* assertion, so the faithful mock is the only usable one here.
*/
const { get, client } = vi.hoisted(() => {
const get = vi.fn();
return { get, client: { get } };
});

vi.mock('../useMetadata', () => ({
useMetadataClient: () => client,
}));

import { useDatasetFieldCatalog } from './useDatasetFields';

/** Minimal object docs: an opportunity that looks up an account, which looks up a region. */
const DOCS: Record<string, Record<string, unknown>> = {
opportunity: {
label: 'Opportunity',
fields: {
amount: { type: 'currency', label: 'Amount' },
account: { type: 'lookup', reference_to: 'account', label: 'Account' },
},
},
account: {
label: 'Account',
fields: {
name: { type: 'text', label: 'Account Name' },
region: { type: 'lookup', reference_to: 'region', label: 'Region' },
},
},
region: {
label: 'Region',
fields: { code: { type: 'text', label: 'Region Code' } },
},
};

beforeEach(() => {
get.mockReset();
get.mockImplementation(async (_type: string, name: string) => DOCS[name] ?? null);
});

afterEach(cleanup);

describe('useDatasetFieldCatalog — the include round-trip', () => {
it('walks a single-hop include path and groups its fields', async () => {
const { result } = renderHook(() => useDatasetFieldCatalog('opportunity', ['account']));

await waitFor(() => expect(result.current.loading).toBe(false));

const values = result.current.fieldOptions.map((o) => o.value);
expect(values).toContain('amount');
expect(values).toContain('account.name');
// The heading is every hop's relationship label plus the target object's
// own label, so a single hop through "Account" onto the Account object
// reads "Account → Account" (buildFieldOptions).
expect(result.current.fieldOptions.find((o) => o.value === 'account.name')?.group).toBe('Account → Account');
});

it('walks a MULTI-hop path — the case the encode/decode pair exists for', async () => {
// `a.b.field` is the ADR-0071 generalisation. If the round-trip mangled the
// path (the real risk when a separator collides with the data), this is
// where it shows: the second hop would never be fetched.
const { result } = renderHook(() => useDatasetFieldCatalog('opportunity', ['account.region']));

await waitFor(() => expect(result.current.loading).toBe(false));

const values = result.current.fieldOptions.map((o) => o.value);
expect(values).toContain('account.region.code');
expect(result.current.fieldOptions.find((o) => o.value === 'account.region.code')?.group).toBe(
'Account → Region → Region',
);
expect(get).toHaveBeenCalledWith('object', 'region');
});

it('keeps several include paths distinct', async () => {
const { result } = renderHook(() => useDatasetFieldCatalog('opportunity', ['account', 'account.region']));

await waitFor(() => expect(result.current.loading).toBe(false));

const values = result.current.fieldOptions.map((o) => o.value);
expect(values).toContain('account.name');
expect(values).toContain('account.region.code');
});

it('treats an empty include list as "base object fields only"', async () => {
// The old code guarded this with a ternary on the joined string, because
// ''.split(sep) yields [''] rather than []. JSON.parse('[]') needs no guard,
// but the observable behaviour must be identical.
const { result } = renderHook(() => useDatasetFieldCatalog('opportunity', []));

await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.fieldOptions.map((o) => o.value)).toEqual(['amount', 'account']);
expect(get).toHaveBeenCalledTimes(1);
});

it('re-runs when include CHANGES but not when it is merely a new array', async () => {
// The whole reason the scalar key exists: `include` is a fresh array on
// every render, so keying on it directly would refetch forever.
const { result, rerender } = renderHook(({ include }) => useDatasetFieldCatalog('opportunity', include), {
initialProps: { include: ['account'] },
});
await waitFor(() => expect(result.current.loading).toBe(false));
const afterFirst = get.mock.calls.length;

// Same contents, new array identity -> no refetch.
rerender({ include: ['account'] });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(get.mock.calls.length).toBe(afterFirst);

// Different contents -> refetch, and the new path resolves.
rerender({ include: ['account.region'] });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(get.mock.calls.length).toBeGreaterThan(afterFirst);
expect(result.current.fieldOptions.map((o) => o.value)).toContain('account.region.code');
});

it('still resolves a path whose segment names contain a comma', async () => {
// A regression guard aimed at the fix itself. The obvious repair for the
// U+0000 separator is "use a printable character no name contains" — which
// just relocates the collision. JSON has no such assumption, so a name that
// contains the would-be separator must still round-trip.
const docs = {
...DOCS,
opportunity: {
label: 'Opportunity',
fields: { 'odd,name': { type: 'lookup', reference_to: 'account', label: 'Odd' } },
},
};
get.mockImplementation(async (_t: string, name: string) => docs[name as keyof typeof docs] ?? null);

const { result } = renderHook(() => useDatasetFieldCatalog('opportunity', ['odd,name']));
await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.fieldOptions.map((o) => o.value)).toContain('odd,name.name');
});

it('falls back to an empty catalog when the base object cannot be read', async () => {
get.mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => useDatasetFieldCatalog('opportunity', ['account']));

await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.fieldOptions).toEqual([]);
expect(result.current.relationships).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,13 @@ export function useDatasetFieldCatalog(
include: string[],
): DatasetFieldCatalog {
const client = useMetadataClient();
const includeKey = include.join('');
// Serialized rather than joined on a separator: this value is only a React
// dependency key, and the separator used to be a raw U+0000 byte picked as
// "a character no path can contain" -- which made this entire file binary to
// grep, so no content search could see it (objectstack#5425). JSON needs no
// impossible character at all, so the round-trip below cannot be broken by
// any path value, and the source stays plain ASCII.
const includeKey = JSON.stringify(include);
const [state, setState] = React.useState<DatasetFieldCatalog>({
relationships: [],
fieldOptions: [],
Expand All @@ -262,7 +268,7 @@ export function useDatasetFieldCatalog(
const baseDoc = await client.get<Record<string, unknown>>('object', object);
if (cancelled) return;
const base = normalizeObject(baseDoc, object);
const includeList = includeKey ? includeKey.split('') : [];
const includeList: string[] = JSON.parse(includeKey);
// Walk each included PATH hop-by-hop, fetching every object along the
// chain (memoized by name) so multi-hop `a.b.field` paths resolve
// (ADR-0021 single-hop, generalized by ADR-0071). Hops can't be fetched
Expand Down Expand Up @@ -314,7 +320,7 @@ export function useDatasetFieldCatalog(
cancelled = true;
};
// includeKey captures the include array by value; eslint-disable the
// exhaustive-deps array-identity warning since we key on the joined string.
// exhaustive-deps array-identity warning since we key on the serialized string.
}, [client, object, includeKey]);

return state;
Expand Down
Loading
Loading