Skip to content
23 changes: 20 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Two complementary guards, and you need both:
scans `src/` for every load-time form (`import from`, `export ... from`, bare `import`, `require`)
of an enforced optional peer, and fails `jest` if one appears. `await import()` is deliberately not
matched - deferring resolution is the fix, not the defect. That file is also the source of truth for
*which* optional peers are enforced: `react` / `react-dom` are marked optional in `package.json` but
_which_ optional peers are enforced: `react` / `react-dom` are marked optional in `package.json` but
exempted there on the record, because every component imports React at module scope and always will.

**2. Built artifact, by hand.** The CI guard reads source, so it cannot speak for what the bundler
Expand Down Expand Up @@ -79,11 +79,28 @@ of examples there, e.g. `FeedbackButton`, `NavigationItem`). Verify by building

## Repo state that looks broken but is pre-existing

`prettier --check` fails on ~82 files and `tsc --noEmit` reports errors across `src/` (including
missing `@types/jest` wiring for `src/__testing__`). Neither is a CI gate - CI runs
`prettier --check` fails on dozens of files and `tsc --noEmit` reports errors across `src/`
(including missing `@types/jest` wiring for `src/__testing__`). Neither is a CI gate - CI runs
`.github/workflows/node-checks.yml` (lint + build) and `jest`. Do not assume you caused these;
do not mass-reformat to "fix" them.

A type contract can still be gated, just not by a type-only assertion file: jest transforms with
`@swc/jest`, which strips types without checking them, so such a file passes no matter what it
asserts. Shell out to `tsc` over a scoped fixture, assert the fixture is in the compiled program
(`--listFiles`) before trusting an empty diagnostic list, then filter the diagnostics to that
fixture - [`src/__testing__/navigationItemTitleTypes.test.ts`](src/__testing__/navigationItemTitleTypes.test.ts)
is the worked pattern, including the checks that keep the filter from turning the guard vacuous.

## Every commit needs a sign-off matching its own author

DCO is a blocking required check with nothing enforcing it locally, and the trailer must match that
commit's own author identity. The rule, the enforcement, and the repair recipe live in
[`CONTRIBUTING.md`](CONTRIBUTING.md#commit-signing) - read it before rewriting history.

The agent-specific trap: the usual offender is a follow-up commit (review fix, doc update, rebase
fixup), not the first one. Sign every commit you add, including the ones an automated pass makes
for you.

## Maintaining this file

Keep this file for knowledge useful to almost every future agent session in this project.
Expand Down
32 changes: 27 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ address (sorry, no pseudonyms or anonymous contributions). An example of signing
$ commit -s -m “my commit message w/signoff”
```

The trailer must match that commit's **own author identity**. `-s` copies your current
`user.name` / `user.email` into the trailer, so a commit authored under a different identity - a
repo-local override, a second machine, a co-author's commit you replayed - still fails the check
even though a `Signed-off-by:` line is present.

To ensure all your commits are signed, you may choose to add this alias to your global `.gitconfig`:

_~/.gitconfig_
Expand All @@ -55,6 +60,23 @@ Or you may configure your IDE, for example, Visual Studio Code to automatically

<a href="https://user-images.githubusercontent.com/7570704/64490167-98906400-d25a-11e9-8b8a-5f465b854d49.png" ><img src="https://user-images.githubusercontent.com/7570704/64490167-98906400-d25a-11e9-8b8a-5f465b854d49.png" width="50%"><a>

### Repairing a failed DCO check

DCO is a blocking required check, enforced by the [probot/dco](https://github.com/probot/dco)
GitHub App rather than by a workflow - there is no file for it under `.github/`, so it is invisible
from the repository tree until a pull request fails. `.husky/commit-msg` is deliberately empty
(commit `612608be`), so nothing catches a missing or mismatched sign-off locally either.

To repair, rebase only the commits you authored, then force-push:

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the fenced-block language.

Change the fence to ```sh so Markdown linting can identify the example language.

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 72-72: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CONTRIBUTING.md` at line 72, Update the fenced code block in CONTRIBUTING.md
to specify the shell language by changing its opening fence to ```sh, while
preserving the example content and closing fence.

Source: Linters/SAST tools

git rebase --signoff <last-correctly-signed-sha>
git push --force-with-lease
```

Pick that starting point deliberately. `--signoff` adds your trailer to every commit it replays, so
rebasing the whole branch grafts your sign-off onto a co-author's commit as a second trailer.

Comment on lines +63 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not present git rebase --signoff as a generic DCO repair.

Line 73 adds a sign-off to every commit replayed, using the current Git identity. That can add mismatched or duplicate trailers to co-authored or otherwise differently authored commits, contradicting the author-identity requirement in Lines 43-47. Restrict this workflow to branches where every replayed commit belongs to the current author, or document an amend-per-commit workflow for mixed-author histories.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~66-~66: The official name of this software platform is spelled with a capital “H”.
Context: ...orkflow - there is no file for it under .github/, so it is invisible from the reposito...

(GITHUB)

🪛 markdownlint-cli2 (0.23.0)

[warning] 72-72: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CONTRIBUTING.md` around lines 63 - 79, Update the “Repairing a failed DCO
check” section around the git rebase --signoff example to limit that workflow to
branches where every replayed commit belongs to the current author. For
mixed-author histories, document an amend-per-commit approach that preserves
each commit’s correct author identity and avoids duplicate or mismatched
sign-off trailers, consistent with the contributor identity requirements in the
earlier guidance.

## <a name="contributing-docs">Documentation Contribution Flow</a>

Please contribute! Layer5 documentation uses Jekyll and GitHub Pages to host docs sites. Learn more about [Layer5's documentation framework](https://docs.google.com/document/d/17guuaxb0xsfutBCzyj2CT6OZiFnMu9w4PzoILXhRXSo/edit?usp=sharing). The process of contributing follows this flow:
Expand Down Expand Up @@ -155,10 +177,10 @@ make lint

`src/custom/ResponsiveDataTable.tsx` wraps `@mui/x-data-grid` (via `@layer5/mui-datatables`) with responsive column visibility and a standardised row action menu. Key exports:

| Export | Description |
|---|---|
| `ResponsiveDataTable` | The main table component |
| `TableAction` | Type for items in the per-row action menu |
| Export | Description |
| ----------------------- | ---------------------------------------------- |
| `ResponsiveDataTable` | The main table component |
| `TableAction` | Type for items in the per-row action menu |
| `getCopyDeepLinkAction` | Helper that builds a "Copy link" `TableAction` |

**`getCopyDeepLinkAction`**
Expand All @@ -171,7 +193,7 @@ import { getCopyDeepLinkAction, TableAction } from '@sistent/sistent';
const rowActions: TableAction[] = [
getCopyDeepLinkAction(() => copyRowDeepLink(row.id)),
// or, with a custom title:
getCopyDeepLinkAction(() => copyRowDeepLink(row.id), t('Copy link')),
getCopyDeepLinkAction(() => copyRowDeepLink(row.id), t('Copy link'))
];
```

Expand Down
54 changes: 54 additions & 0 deletions src/__testing__/fixtures/navigationItemTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Type-level fixture for `NavigationItem['title']`. It is compiled by
// `navigationItemTitleTypes.test.ts` via `tsc --noEmit`, never executed, and is
// deliberately outside jest's `*.test.*` glob so jest does not collect it.
//
// `NavigationNavbar` renders `title` into MUI's `ListItemText` `primary` slot,
// which is typed `React.ReactNode`. Declaring `title` as `string` therefore
// rejected composed labels that already rendered correctly - see
// https://github.com/layer5io/sistent/issues/1746.
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import type { NavigationItem } from '../../custom/NavigationNavbar';

// A plain string still compiles - the widening is source-compatible.
export const plainStringTitle: NavigationItem = {
id: 'settings',
title: 'Settings',
onClick: () => {}
};

// A composed ReactNode title compiles: the exact shape that previously forced
// consumers into an `as unknown as string` assertion.
export const composedNodeTitle: NavigationItem = {
id: 'api-docs',
title: (
<span>
API Docs
<OpenInNewIcon fontSize="small" />
</span>
),
onClick: () => {}
};

// Sub-items share the type, so they gain the same latitude.
export const composedSubItemTitle: NavigationItem = {
id: 'docs',
title: 'Docs',
onClick: () => {},
subItems: [
{
id: 'docs-api',
title: <span>API</span>,
onClick: () => {}
}
]
};

// Self-check: if the compiler ever stops type-checking this fixture, the
// suppression below goes unused and tsc reports TS2578 here, failing the test
// rather than letting it pass vacuously. A plain object is not a `ReactNode`.
export const rejectsNonNode: NavigationItem = {
id: 'bad',
// @ts-expect-error - a plain object is not assignable to React.ReactNode
title: { label: 'not a node' },
onClick: () => {}
};
7 changes: 7 additions & 0 deletions src/__testing__/fixtures/tsconfig.navigationItemTitle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./navigationItemTitle.tsx"]
}
125 changes: 125 additions & 0 deletions src/__testing__/navigationItemTitleTypes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { spawnSync } from 'child_process';
import path from 'path';

// Type-level regression guard for https://github.com/layer5io/sistent/issues/1746.
//
// `NavigationNavbar` renders `NavigationItem['title']` into MUI's `ListItemText`
// `primary` slot, which is typed `React.ReactNode`. While `title` was declared
// `string`, composed labels rendered correctly but did not type-check, and
// consumers reached for `as unknown as string`.
//
// jest transforms with @swc/jest, which strips types without checking them, so a
// plain type-only assertion file would pass no matter what `title` is declared
// as. This test therefore shells out to `tsc` over a fixture that exercises both
// a plain string and a composed `ReactNode` title.
//
// Diagnostics are filtered to the fixture itself: type-checking it pulls in the
// component's transitive dependencies, which carry pre-existing errors unrelated
// to this contract (see CLAUDE.md, "Repo state that looks broken but is
// pre-existing"). The fixture's own `@ts-expect-error` self-check fails loudly if
// the compiler ever stops checking it, so filtering cannot make this vacuous.
//
// Everything below the filter exists so that the guard fails closed. Filtering
// is the one place a compiler check can quietly turn into a no-op: every outcome
// that is not "tsc ran, compiled the fixture, and reported on it" has to be made
// to fail rather than collapse into an empty, green list of diagnostics.

const FIXTURE = 'src/__testing__/fixtures/navigationItemTitle.tsx';
const PROJECT = 'src/__testing__/fixtures/tsconfig.navigationItemTitle.json';
const repoRoot = path.resolve(__dirname, '..', '..');

// `<file>(<line>,<col>): error TS….` The file part is what scopes a diagnostic,
// so it is parsed rather than prefix-matched: tsc prints paths relative to its
// cwd only while the fixture stays under it, and a bare `startsWith` would read
// any other emission as "the fixture is clean".
const FILE_SCOPED_DIAGNOSTIC = /^(.+?)\(\d+,\d+\): error TS\d+/;

// `error TS…` with no file part is a config-level failure - TS18003 "No inputs
// were found in config file", TS5083 "Cannot read file" - and means the fixture
// was never compiled at all. The fixture filter drops these on the floor, and
// the fixture's `@ts-expect-error` self-check cannot see them either, because
// neither fires unless the compiler had the fixture in its program.
const CONFIG_SCOPED_DIAGNOSTIC = /^error TS\d+/;

Comment on lines +35 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

sed -n '1,140p' src/__testing__/navigationItemTitleTypes.test.ts

Repository: layer5io/sistent

Length of output: 4718


🏁 Script executed:

sed -n '1,140p' src/__testing__/navigationItemTitleTypes.test.ts && printf '\n---\n' && sed -n '1,220p' src/__testing__/packageJsonTestUtils.ts

Repository: layer5io/sistent

Length of output: 4806


🏁 Script executed:

sed -n '1,220p' src/__testing__/packageJsonTestUtils.ts

Repository: layer5io/sistent

Length of output: 237


🏁 Script executed:

printf '%s\n' '--- navigationItemTitleTypes.test.ts ---'
cat -n src/__testing__/navigationItemTitleTypes.test.ts
printf '\n%s\n' '--- matching fixture files ---'
fd -a 'navigationItemTitle*' src/__testing__
printf '\n%s\n' '--- fixture tsconfig ---'
cat -n src/__testing__/fixtures/tsconfig.navigationItemTitle.json
printf '\n%s\n' '--- fixture source ---'
cat -n src/__testing__/fixtures/navigationItemTitle.tsx

Repository: layer5io/sistent

Length of output: 8159


🏁 Script executed:

printf '%s\n' '--- fixture tsconfig files ---'
fd -a 'tsconfig.navigationItemTitle.json' src/__testing__/fixtures
printf '\n%s\n' '--- fixture tsconfig ---'
cat -n src/__testing__/fixtures/tsconfig.navigationItemTitle.json
printf '\n%s\n' '--- fixture source ---'
cat -n src/__testing__/fixtures/navigationItemTitle.tsx

Repository: layer5io/sistent

Length of output: 2717


Make the compiler guard fail closed. src/__testing__/navigationItemTitleTypes.test.ts:35-83 still drops any tsc output that doesn’t match the two regexes, so a config/include failure can leave both diagnostics.config and diagnostics.fixture empty and let the test pass without proving the fixture entered the program. Treat unmatched diagnostics as failures and assert the fixture is present in the compilation output.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawnSync } from 'child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__testing__/navigationItemTitleTypes.test.ts` around lines 35 - 43, The
compiler-output parsing in the navigation item title diagnostics test must fail
closed: classify every non-empty tsc diagnostic, including unmatched formats, as
a failure instead of dropping it. Update the diagnostics flow around
FILE_SCOPED_DIAGNOSTIC and CONFIG_SCOPED_DIAGNOSTIC so config failures are
reported and add an assertion that the expected fixture appears in compilation
output before allowing the test to pass.

Source: Coding guidelines

// Absence of diagnostics only means "nothing was wrong with what tsc compiled" -
// it says nothing about *what* tsc compiled. A project whose `include` resolves
// to some other existing file emits no diagnostic at all, so both lists above
// come back empty and the guard passes without ever having looked at the
// fixture. `--listFiles` closes that by making the program's contents an
// assertable fact instead of an assumption.
const asPosix = (value: string): string => value.replace(/\\/g, '/');

type Diagnostics = { fixture: string[]; config: string[]; compiledFixture: boolean };

// Below the 180s `beforeAll` budget. `spawnSync` blocks the jest worker outright,
// and jest's own timeout cannot preempt a synchronous child, so a wedged tsc
// would otherwise hold the worker until the whole run is killed. Node's timeout
// signals the child, which surfaces as the `status === null` throw below.
const TSC_TIMEOUT_MS = 150_000;

const typeCheckFixture = (): Diagnostics => {
// Spawned through `process.execPath` rather than `npx`: no shell, no PATH
// lookup, and no `npx` vs `npx.cmd` divergence on Windows, where an ENOENT
// would otherwise be caught and laundered into the diagnostic output.
const tsc = spawnSync(
process.execPath,
[require.resolve('typescript/bin/tsc'), '-p', PROJECT, '--pretty', 'false', '--listFiles'],
{
cwd: repoRoot,
encoding: 'utf8',
timeout: TSC_TIMEOUT_MS,
// tsc reports on every file in the program, and `--listFiles` prints each
// one, so this runs to several MB. The 1 MB default would kill the child
// mid-stream and truncate stdout - and the fixture is a root file, so its
// diagnostics are emitted last and lost first.
maxBuffer: 32 * 1024 * 1024
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

// A compiler that never ran is not a compiler that found nothing.
if (tsc.error) throw tsc.error;
if (tsc.status === null) {
throw new Error(`tsc was killed by ${tsc.signal ?? 'an unknown signal'} before reporting`);
}

// tsc exits non-zero whenever any file in the program has an error, including
// the pre-existing ones outside the fixture, so the exit code is not the
// verdict here - the diagnostics are.
const lines = `${tsc.stdout}\n${tsc.stderr}`.split('\n').map((line) => line.trim());

const belongsToFixture = (line: string): boolean => {
const file = FILE_SCOPED_DIAGNOSTIC.exec(line)?.[1];
return file !== undefined && asPosix(file).endsWith(FIXTURE);
};

return {
fixture: lines.filter(belongsToFixture),
config: lines.filter((line) => CONFIG_SCOPED_DIAGNOSTIC.test(line)),
// `--listFiles` prints one absolute path per program file, diagnostics aside.
compiledFixture: lines.some(
(line) => !FILE_SCOPED_DIAGNOSTIC.test(line) && asPosix(line).endsWith(FIXTURE)
)
};
};

describe('NavigationItem title type contract', () => {
let diagnostics: Diagnostics;

beforeAll(() => {
diagnostics = typeCheckFixture();
}, 180_000);

// Ordered deliberately: the two assertions below are only meaningful once the
// fixture is known to have been compiled, so prove that first.
it('compiles the fixture', () => {
expect(diagnostics.compiledFixture).toBe(true);
});

it('reports no config-level failure that would skip the fixture', () => {
expect(diagnostics.config).toEqual([]);
});

it('accepts both a plain string and a composed ReactNode title', () => {
expect(diagnostics.fixture).toEqual([]);
});
});
9 changes: 8 additions & 1 deletion src/custom/NavigationNavbar/navigationNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import { IconWrapper, MenuItemList, MenuItemSubList, MenuListStyle, SubIconWrapp

export type NavigationItem = {
id: string;
title: string;
/**
* Label rendered into `ListItemText`'s `primary` slot.
*
* Accepts any `React.ReactNode`, not just a string, so composed labels -
* a label plus a trailing external-link glyph, a `<Chip>` badge, a count -
* type-check as well as they already render.
*/
title: React.ReactNode;
icon?: React.ReactNode;
/**
* Legacy boolean permission flag.
Expand Down
Loading