Skip to content
40 changes: 40 additions & 0 deletions flip-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,46 @@ Client ID are required for a production deployment.
> [`vite.config.mts`](vite.config.mts)). Use `VITE_LOCAL` only with `npm run dev` against a mocked API, and never
> set `VITE_DEMO` at all — the demo build gets it from `--mode demo`. Keep both out of CI and deploy environments.

### Dependency scoping

**Production source may not import a package that is only declared in `devDependencies`, nor one that is
declared nowhere and resolves through npm's hoisting of a parent's tree.** "Production source" is everything
the deployed bundles reach: `src/`, minus its test files, plus `mocks/` — which `build:demo` pulls into the
public `/ark_demo` bundle.

This is enforced, not just documented: the `flip-ui/dependency-scoping` block in
[`eslint.config.mjs`](eslint.config.mjs) runs `import-x/no-extraneous-dependencies` over exactly that file
set, so `npm run lint` fails in CI the moment an import lands in the wrong stanza.

The rule exists for **Dependabot**, not the build. `vite build` tree-shakes from the entry points and bundles
whatever they reach whichever stanza a package sits in, and every install path in the repo (`npm ci` in CI,
the Dockerfile, `make deploy-ui`) installs both stanzas — so a wrong stanza breaks nothing and is invisible
until it matters. What it changes is the **scope label on a security alert**: a package that ships to users
but sits in `devDependencies` produces an alert labelled "Development", which reads as *not in the production
bundle* and invites a wrongly-dismissed alert on code CloudFront is serving. FLIP#1041 corrected 21
packages: 18 moved out of `devDependencies`, two that were declared nowhere at all (`codemirror`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This states "18 moved out of devDependencies, two that were declared nowhere at all (codemirror, tippy.js)". Based on the package.json/lockfile diff, vuejs-smart-table was also declared in neither stanza before this PR (no prior dependencies/devDependencies entry, no prior lockfile node) -- so it looks like the "declared nowhere" bucket should be 3, not 2, and the "moved from devDependencies" count should be adjusted down accordingly.

`tippy.js` — they resolved only by hoisting), and `husky`, which had drifted the other way. Correct scoping
is also the precondition for ever adopting `npm ci --omit=dev` here.

Note the rule keys off the **import graph, not the bundle**. `@popperjs/core` is a `dependency` even though
tree-shaking currently drops it (its only consumers, `AiSelect`/`AiChipSelect`, are unused) — declaring on
what production code imports is stable, whereas declaring on what survives tree-shaking silently flips the
correct answer whenever an unrelated component starts or stops being used.

Two traps when auditing this by hand:

- **A `from "..."` grep under-reports.** `highlight.js` is loaded lazily via
`import("highlight.js/lib/core")` and `import("highlight.js/lib/languages/json")` in
[`src/utils/highlightJson.ts`](src/utils/highlightJson.ts), and is correctly a `dependency` despite having
no static import. Test files also live under `src/` (107 in `__tests__/` directories plus two flat
`*.spec.ts`), and their imports — `vitest`, `@vue/test-utils`, `@pinia/testing` — are genuinely dev-only.
- **A static import of a side-effecting module is never dropped, even from a branch that folds.** Neither
mock server may be imported statically from [`src/main.ts`](src/main.ts); both are loaded through dynamic
`import()` inside their own folded branch. `mocks/server` was static until FLIP#1041 and shipped ~230
modules — Mirage, Pretender, route-recognizer, inflected and all of lodash — in the production entry
chunk of every build, because miragejs patches `Error.prototype` at module scope. Making it dynamic cut
the entry chunk from 331 KB to 182 KB. See the comment above `bootstrap()`.

## Testing

### Unit tests (Vitest)
Expand Down
28 changes: 28 additions & 0 deletions flip-ui/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import stylistic from "@stylistic/eslint-plugin";
import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript";
import importX from "eslint-plugin-import-x";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import pluginVue from "eslint-plugin-vue";

Expand Down Expand Up @@ -141,4 +142,31 @@ export default defineConfigWithVueTs(
},
},
},

{
// Dependency scoping guard (FLIP#1041). Production source may not import a
// package that is only declared in `devDependencies`, nor one that is declared
// nowhere and resolves solely through npm's hoisting of a parent's tree.
//
// The point is not the build - vite bundles whatever the entry points reach,
// whichever stanza a package sits in. It is that Dependabot derives a security
// alert's scope label from the stanza, so a shipped package left in
// `devDependencies` yields an alert labelled "Development" that reads as
// not-user-facing. See flip-ui/README.md -> Dependency scoping.
//
// `mocks/` is deliberately NOT exempt: mocks/demo-server.ts ships in the public
// /ark_demo bundle, so its imports are production imports.
name: "flip-ui/dependency-scoping",
files: ["src/**/*.ts", "src/**/*.vue", "mocks/**/*.ts"],
ignores: ["**/*.spec.ts", "**/__tests__/**"],
plugins: { "import-x": importX },
rules: {
"import-x/no-extraneous-dependencies": ["error", {
devDependencies: false,
optionalDependencies: false,
peerDependencies: true,
includeTypes: false,
}],
},
},
);
16 changes: 13 additions & 3 deletions flip-ui/mocks/demo-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,11 @@ const allModels = (): unknown => {
// Every recorded run finished, so the trust list is the project's own — the
// real endpoint returns [] only for models that never reached dispatch.
const runTrusts = (trusts as { id: string; name: string; code: string }[])
.map(({ id, name, code }) => ({ id, name, code }));
.map(({ id, name, code }) => ({
id,
name,
code
}));

const data: Record<string, unknown>[] = rows.map(({ model, project }) => ({
...model,
Expand All @@ -210,7 +214,10 @@ const allModels = (): unknown => {
const statusCounts = data.reduce<Record<string, number>>((counts, model) => {
const status = String(model.status);

return { ...counts, [status]: (counts[status] ?? 0) + 1 };
return {
...counts,
[status]: (counts[status] ?? 0) + 1
};
}, {});

return {
Expand Down Expand Up @@ -238,7 +245,10 @@ const flNets = (): unknown => {
lastConnected: new Date().toUTCString()
}));

return [{ name: "net-1", clients }];
return [{
name: "net-1",
clients
}];
};

/**
Expand Down
4 changes: 1 addition & 3 deletions flip-ui/mocks/roles/seed-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,4 @@ export const roleResearcher: IRole = {
roledescription: "A researcher."
};

export const allRoles: IRoleResponse = {
roles: [roleAdmin, roleResearcher]
};
export const allRoles: IRoleResponse = { roles: [roleAdmin, roleResearcher] };
12 changes: 10 additions & 2 deletions flip-ui/mocks/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ const projectsModel: ModelDefinition<IProject> = Model.extend({});
const usersModel: ModelDefinition<IUser> = Model.extend({});
const detailsModel: ModelDefinition<ISiteDetails> = Model.extend({});

// eslint-disable-next-line @typescript-eslint/ban-types
// Mirage's Registry is parameterised by the model and factory maps; this app
// registers neither, so both are genuinely empty. `ban-types` was removed in
// typescript-eslint v8 and split into `no-empty-object-type` (FLIP#1041 — the
// old disable named a rule that no longer exists, so it suppressed nothing).
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type AppRegistry = Registry<{}, {}>;

type AppSchema = Schema<AppRegistry>
Expand Down Expand Up @@ -270,7 +274,11 @@ export const makeServer = ({ environment = "development" } = {}): Server<AppRegi
// Mirror the hub: name and organisation are withheld from this route.
const { id, email, isDisabled } = user;

return new Response(200, undefined, { id, email, isDisabled });
return new Response(200, undefined, {
id,
email,
isDisabled
});
});

// #endregion
Expand Down
Loading
Loading