@ogygie/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +77 -0
- package/src/bundle-budget.test.ts +64 -0
- package/src/bundle-budget.ts +51 -0
- package/src/clock.ts +24 -0
- package/src/contract-purity.test.ts +185 -0
- package/src/contract-purity.ts +213 -0
- package/src/db.ts +9 -0
- package/src/decision-refs.test.ts +114 -0
- package/src/decision-refs.ts +103 -0
- package/src/design-brief.test.ts +221 -0
- package/src/design-brief.ts +581 -0
- package/src/error.schema.ts +62 -0
- package/src/examples/erreur-convention-ci.json +10 -0
- package/src/examples/erreur-validation-runtime.json +10 -0
- package/src/manifest-client.test.ts +287 -0
- package/src/manifest-client.ts +313 -0
- package/src/manifest-gateway.test.ts +541 -0
- package/src/manifest-gateway.ts +392 -0
- package/src/manifest.schema.test.ts +255 -0
- package/src/manifest.schema.ts +187 -0
- package/src/module-purity.test.ts +117 -0
- package/src/module-purity.ts +127 -0
- package/src/product-vocabulary.scan.test.ts +142 -0
- package/src/shared-tables.test.ts +136 -0
- package/src/shared-tables.ts +113 -0
- package/src/spec.schema.test.ts +143 -0
- package/src/spec.schema.ts +182 -0
- package/src/tenant.ts +207 -0
- package/src/test-kit.ts +30 -0
- package/src/vocabulary.test.ts +46 -0
- package/src/vocabulary.ts +36 -0
- package/src/web-lint.test.ts +100 -0
- package/src/web-lint.ts +176 -0
package/src/tenant.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Structural multi-tenancy (AGENTS.md §Multi-tenant). The core exposes a
|
|
3
|
+
* GENERIC TenantContext — it knows no word of the product domain (DECISIONS.md D10):
|
|
4
|
+
* the product defines its own alias and binds the tenant column key.
|
|
5
|
+
* `tenantDb(ctx)` is the only sanctioned way to touch tenant-scoped tables: it
|
|
6
|
+
* injects the tenant column on insert and filters on it for select/update/delete,
|
|
7
|
+
* making cross-tenant leaks impossible by construction rather than by vigilance.
|
|
8
|
+
*/
|
|
9
|
+
import type { InferInsertModel, InferSelectModel, SQL } from "drizzle-orm";
|
|
10
|
+
import { and, eq, getTableColumns } from "drizzle-orm";
|
|
11
|
+
import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { OgygieFailure, ogygieError } from "./error.schema.js";
|
|
14
|
+
import type { Database } from "./db.js";
|
|
15
|
+
|
|
16
|
+
export const TenantContextSchema = z.object({
|
|
17
|
+
tenantId: z.uuid(),
|
|
18
|
+
userId: z.uuid(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tenant context built EXCLUSIVELY by the product's auth middleware — never from
|
|
23
|
+
* client-provided identifiers (body/query/params are hostile). Besides the two
|
|
24
|
+
* identity fields it carries the effect bindings: the request-scoped db and the
|
|
25
|
+
* TS property key of the tenant column on the product's Drizzle tables. Nothing
|
|
26
|
+
* else needs to be injected into repositories.
|
|
27
|
+
*/
|
|
28
|
+
export interface TenantContext<TenantColumnKey extends string = string>
|
|
29
|
+
extends z.infer<typeof TenantContextSchema> {
|
|
30
|
+
readonly db: Database;
|
|
31
|
+
readonly tenantColumnKey: TenantColumnKey;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Rows come back untyped from the generic facade; repositories narrow them. */
|
|
35
|
+
type RawRow = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* Generic-bridge helpers. Drizzle's builder generics only resolve for a
|
|
39
|
+
* concrete table type, so these helpers work on the abstract `PgTable` with
|
|
40
|
+
* untyped rows. Runtime correctness is guaranteed by the concrete table
|
|
41
|
+
* schema; the facade's public signatures below stay fully typed for callers.
|
|
42
|
+
*/
|
|
43
|
+
async function rawInsertReturning(db: Database, table: PgTable, values: object): Promise<RawRow[]> {
|
|
44
|
+
return db.insert(table).values(values).returning();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function rawInsert(db: Database, table: PgTable, values: object): Promise<void> {
|
|
48
|
+
await db.insert(table).values(values);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function rawSelect(db: Database, table: PgTable, condition: SQL): Promise<RawRow[]> {
|
|
52
|
+
return db.select().from(table).where(condition);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function rawUpdate(db: Database, table: PgTable, values: object, condition: SQL): Promise<void> {
|
|
56
|
+
await db.update(table).set(values).where(condition);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function rawDelete(db: Database, table: PgTable, condition: SQL): Promise<void> {
|
|
60
|
+
await db.delete(table).where(condition);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tenantColumnOf(table: PgTable, tenantColumnKey: string): PgColumn {
|
|
64
|
+
const columns: Record<string, PgColumn> = getTableColumns(table);
|
|
65
|
+
const column = columns[tenantColumnKey];
|
|
66
|
+
if (column === undefined) {
|
|
67
|
+
throw new OgygieFailure(
|
|
68
|
+
ogygieError({
|
|
69
|
+
code: "TENANT_COLUMN_MISSING",
|
|
70
|
+
message: `Table has no tenant column with TS key "${tenantColumnKey}" — it cannot be accessed through tenantDb.`,
|
|
71
|
+
file: "packages/ogygie-core/src/tenant.ts",
|
|
72
|
+
cause: `getTableColumns() returned no column for key "${tenantColumnKey}"`,
|
|
73
|
+
suggestedFix:
|
|
74
|
+
"Add the tenant column to the table's Drizzle schema, or — for a genuinely shared table — declare it `shared: true` in the manifest and do not use tenantDb.",
|
|
75
|
+
}),
|
|
76
|
+
500,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return column;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Public contract of the tenant-scoped facade. Named (rather than inferred)
|
|
84
|
+
* because `transaction` hands the SAME contract back to its callback: the
|
|
85
|
+
* transactional facade offers exactly what the plain one does — including
|
|
86
|
+
* nested transactions (Postgres savepoints via drizzle).
|
|
87
|
+
*/
|
|
88
|
+
export interface TenantDb<Key extends string> {
|
|
89
|
+
insert<T extends PgTable>(table: T): {
|
|
90
|
+
values(values: Omit<InferInsertModel<T>, Key>): {
|
|
91
|
+
returning(): Promise<InferSelectModel<T>[]>;
|
|
92
|
+
execute(): Promise<void>;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
select<T extends PgTable>(table: T): {
|
|
96
|
+
/** All rows of the tenant matching the extra condition (if any). */
|
|
97
|
+
where(condition?: SQL): Promise<InferSelectModel<T>[]>;
|
|
98
|
+
/** All rows of the tenant. */
|
|
99
|
+
all(): Promise<InferSelectModel<T>[]>;
|
|
100
|
+
};
|
|
101
|
+
update<T extends PgTable>(table: T): {
|
|
102
|
+
set(values: Partial<Omit<InferInsertModel<T>, Key>>): {
|
|
103
|
+
where(condition?: SQL): Promise<void>;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
delete<T extends PgTable>(table: T): {
|
|
107
|
+
where(condition?: SQL): Promise<void>;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Runs `work` inside ONE database transaction: every write made through the
|
|
111
|
+
* callback's facade commits or rolls back together. The callback receives a
|
|
112
|
+
* facade with the same tenant guarantees (injection on insert, filtering on
|
|
113
|
+
* select/update/delete) — never a raw query builder. Born from a product
|
|
114
|
+
* review: multi-write decisions must be atomic, and repositories had no
|
|
115
|
+
* sanctioned way to get a transaction without touching the raw db.
|
|
116
|
+
*/
|
|
117
|
+
transaction<R>(work: (transactionDb: TenantDb<Key>) => Promise<R>): Promise<R>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Tenant-scoped facade over Drizzle. Deliberately minimal (insert/select/update/
|
|
122
|
+
* delete/transaction): repositories never see an unfiltered query builder. The
|
|
123
|
+
* tenant value always comes from ctx, never from the caller's values or
|
|
124
|
+
* conditions.
|
|
125
|
+
*/
|
|
126
|
+
export function tenantDb<Key extends string>(ctx: TenantContext<Key>): TenantDb<Key> {
|
|
127
|
+
return {
|
|
128
|
+
insert<T extends PgTable>(table: T) {
|
|
129
|
+
tenantColumnOf(table, ctx.tenantColumnKey);
|
|
130
|
+
return {
|
|
131
|
+
values(values: Omit<InferInsertModel<T>, Key>) {
|
|
132
|
+
const complete = {
|
|
133
|
+
...values,
|
|
134
|
+
[ctx.tenantColumnKey]: ctx.tenantId,
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
returning: async (): Promise<InferSelectModel<T>[]> => {
|
|
138
|
+
const rows = await rawInsertReturning(ctx.db, table, complete);
|
|
139
|
+
return rows as InferSelectModel<T>[];
|
|
140
|
+
},
|
|
141
|
+
execute: async (): Promise<void> => {
|
|
142
|
+
await rawInsert(ctx.db, table, complete);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
select<T extends PgTable>(table: T) {
|
|
150
|
+
const tenantFilter = eq(tenantColumnOf(table, ctx.tenantColumnKey), ctx.tenantId);
|
|
151
|
+
return {
|
|
152
|
+
/** All rows of the tenant matching the extra condition (if any). */
|
|
153
|
+
where: async (condition?: SQL): Promise<InferSelectModel<T>[]> => {
|
|
154
|
+
const combined =
|
|
155
|
+
condition === undefined ? tenantFilter : and(tenantFilter, condition);
|
|
156
|
+
const rows = await rawSelect(ctx.db, table, combined ?? tenantFilter);
|
|
157
|
+
return rows as InferSelectModel<T>[];
|
|
158
|
+
},
|
|
159
|
+
/** All rows of the tenant. */
|
|
160
|
+
all: async (): Promise<InferSelectModel<T>[]> => {
|
|
161
|
+
const rows = await rawSelect(ctx.db, table, tenantFilter);
|
|
162
|
+
return rows as InferSelectModel<T>[];
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
update<T extends PgTable>(table: T) {
|
|
168
|
+
const tenantFilter = eq(tenantColumnOf(table, ctx.tenantColumnKey), ctx.tenantId);
|
|
169
|
+
return {
|
|
170
|
+
set(values: Partial<Omit<InferInsertModel<T>, Key>>) {
|
|
171
|
+
return {
|
|
172
|
+
where: async (condition?: SQL): Promise<void> => {
|
|
173
|
+
const combined =
|
|
174
|
+
condition === undefined ? tenantFilter : and(tenantFilter, condition);
|
|
175
|
+
await rawUpdate(ctx.db, table, values, combined ?? tenantFilter);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
delete<T extends PgTable>(table: T) {
|
|
183
|
+
const tenantFilter = eq(tenantColumnOf(table, ctx.tenantColumnKey), ctx.tenantId);
|
|
184
|
+
return {
|
|
185
|
+
where: async (condition?: SQL): Promise<void> => {
|
|
186
|
+
const combined =
|
|
187
|
+
condition === undefined ? tenantFilter : and(tenantFilter, condition);
|
|
188
|
+
await rawDelete(ctx.db, table, combined ?? tenantFilter);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async transaction<R>(work: (transactionDb: TenantDb<Key>) => Promise<R>): Promise<R> {
|
|
194
|
+
return ctx.db.transaction(async (transactionScopedDb) => {
|
|
195
|
+
// Rebuilt field by field (not spread) so the type system proves the
|
|
196
|
+
// transactional context carries exactly the tenant contract.
|
|
197
|
+
const transactionCtx: TenantContext<Key> = {
|
|
198
|
+
tenantId: ctx.tenantId,
|
|
199
|
+
userId: ctx.userId,
|
|
200
|
+
tenantColumnKey: ctx.tenantColumnKey,
|
|
201
|
+
db: transactionScopedDb,
|
|
202
|
+
};
|
|
203
|
+
return work(tenantDb(transactionCtx));
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
package/src/test-kit.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Test kit: in-memory Postgres (PGlite) + frozen clock. No network, no
|
|
3
|
+
* real DB — feature tests must pass in seconds (AGENTS.md principle 7).
|
|
4
|
+
* The core knows nothing about product tables: the product passes the content
|
|
5
|
+
* of its own migration files.
|
|
6
|
+
*/
|
|
7
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
8
|
+
import { drizzle } from "drizzle-orm/pglite";
|
|
9
|
+
import type { Database } from "./db.js";
|
|
10
|
+
|
|
11
|
+
export { fixedClock } from "./clock.js";
|
|
12
|
+
|
|
13
|
+
export interface InMemoryDb {
|
|
14
|
+
db: Database;
|
|
15
|
+
/** Raw PGlite client, for SQL-level assertions in tests. */
|
|
16
|
+
raw: PGlite;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Creates an in-memory Postgres and applies the given SQL migrations in order. */
|
|
21
|
+
export async function createInMemoryDb(
|
|
22
|
+
migrationsSql: readonly string[],
|
|
23
|
+
): Promise<InMemoryDb> {
|
|
24
|
+
const client = new PGlite();
|
|
25
|
+
for (const sql of migrationsSql) {
|
|
26
|
+
await client.exec(sql);
|
|
27
|
+
}
|
|
28
|
+
const db: Database = drizzle(client);
|
|
29
|
+
return { db, raw: client, close: () => client.close() };
|
|
30
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Mutation tests of the vocabulary CHECKER itself (D22): the matching
|
|
3
|
+
* semantics — word start, case-insensitivity, hyphens, regex escaping — are
|
|
4
|
+
* what the whole gate rests on, so each one is proven with INVENTED stems.
|
|
5
|
+
* The real forbidden list, and the scan of the core's files against it, live
|
|
6
|
+
* in product-vocabulary.scan.test.ts (the only file allowed to write the
|
|
7
|
+
* forbidden words).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { findProductVocabulary } from "./vocabulary.js";
|
|
11
|
+
|
|
12
|
+
describe("findProductVocabulary — sémantique du matching", () => {
|
|
13
|
+
it("détecte un stem en début de mot, jamais en plein milieu", () => {
|
|
14
|
+
expect(findProductVocabulary("the embedder policy", ["bed"])).toEqual([]);
|
|
15
|
+
expect(findProductVocabulary("const bedCount = 2;", ["bed"])).toEqual(["bed"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("un stem attrape ses dérivés camelCase et ses pluriels", () => {
|
|
19
|
+
expect(findProductVocabulary("relayGadgets(gadgetId)", ["gadget"])).toEqual(["gadget"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("insensible à la casse", () => {
|
|
23
|
+
expect(findProductVocabulary("import { Gadget } from x", ["gadget"])).toEqual(["gadget"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("un tiret est un début de mot : un id kebab-case déclenche chacun de ses stems", () => {
|
|
27
|
+
expect(findProductVocabulary("the gadget-registry feature", ["gadget-registry", "registry"])).toEqual([
|
|
28
|
+
"gadget-registry",
|
|
29
|
+
"registry",
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rend les stems dans l'ordre de la liste, une seule fois chacun", () => {
|
|
34
|
+
const source = "a gadget, another gadget, a widget";
|
|
35
|
+
expect(findProductVocabulary(source, ["widget", "gadget"])).toEqual(["widget", "gadget"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("échappe les caractères spéciaux de regex dans les stems", () => {
|
|
39
|
+
expect(findProductVocabulary("uses c++ everywhere", ["c++"])).toEqual(["c++"]);
|
|
40
|
+
expect(findProductVocabulary("plain c here", ["c++"])).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("une source sans aucun stem interdit rend une liste vide", () => {
|
|
44
|
+
expect(findProductVocabulary("generic relay of manifest routes", ["gadget"])).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Testable heart of the vocabulary gate (DECISIONS.md D10 rule 1: a
|
|
3
|
+
* framework module names NO product concept — no domain word, no product name,
|
|
4
|
+
* no feature id). Pure function — source text and stems in, findings out — so
|
|
5
|
+
* the scan test and the mutation tests required by D22 feed it directly.
|
|
6
|
+
* Extracted on its THIRD occurrence (rule of three, AGENTS.md §Workflow 5):
|
|
7
|
+
* the first two copies lived in the vocabulary tests of the manifest-derived
|
|
8
|
+
* gateway and client. The stems themselves are product configuration and do
|
|
9
|
+
* NOT live here: the caller owns its forbidden list.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every forbidden stem the source names, in the order of the given list —
|
|
14
|
+
* one entry per stem, however many times it appears.
|
|
15
|
+
*
|
|
16
|
+
* A stem matches case-insensitively at a WORD START (`(?<![\p{L}\p{N}])`),
|
|
17
|
+
* not as a bare substring: "embedder" must not trip on a stem "bed", but a
|
|
18
|
+
* camel-case usage like "tenantId" must trip on a stem "tenant". Hyphens are
|
|
19
|
+
* word starts too, so a kebab-case id is caught both as a full id and through
|
|
20
|
+
* every stem it contains.
|
|
21
|
+
*/
|
|
22
|
+
export function findProductVocabulary(
|
|
23
|
+
source: string,
|
|
24
|
+
forbiddenStems: readonly string[],
|
|
25
|
+
): string[] {
|
|
26
|
+
const found: string[] = [];
|
|
27
|
+
for (const stem of forbiddenStems) {
|
|
28
|
+
// No "-" in this class: in unicode mode an escaped hyphen is itself an
|
|
29
|
+
// invalid escape, and a hyphen needs no escaping outside a class.
|
|
30
|
+
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
if (new RegExp(`(?<![\\p{L}\\p{N}])${escaped}`, "iu").test(source)) {
|
|
32
|
+
found.push(stem);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Mutation tests of the web lint gate (D22): each KNOWN violation is
|
|
3
|
+
* proven detected, and each legitimate pattern is proven accepted.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
findAbsoluteApiUrls,
|
|
8
|
+
findArbitraryTailwindValues,
|
|
9
|
+
findHardcodedJsxText,
|
|
10
|
+
} from "./web-lint.js";
|
|
11
|
+
|
|
12
|
+
describe("web-lint — chaînes en dur dans le JSX", () => {
|
|
13
|
+
it("MUTATION : un texte JSX hors <Trans> est détecté", () => {
|
|
14
|
+
const source = `export function Screen() { return <p>Ajoutez votre première entrée</p>; }`;
|
|
15
|
+
const violations = findHardcodedJsxText(source);
|
|
16
|
+
expect(violations).toHaveLength(1);
|
|
17
|
+
expect(violations[0]?.excerpt).toContain("Ajoutez");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("un texte enveloppé dans <Trans> passe (fr est la locale source)", () => {
|
|
21
|
+
const source = `export function Screen() { return <p><Trans>Ajoutez votre première entrée</Trans></p>; }`;
|
|
22
|
+
expect(findHardcodedJsxText(source)).toHaveLength(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("MUTATION : un texte dans un élément IMBRIQUÉ hors Trans est détecté même si un Trans existe ailleurs", () => {
|
|
26
|
+
const source = `export function Screen() {
|
|
27
|
+
return <div><Trans>Bonjour</Trans><span>texte oublié</span></div>;
|
|
28
|
+
}`;
|
|
29
|
+
const violations = findHardcodedJsxText(source);
|
|
30
|
+
expect(violations).toHaveLength(1);
|
|
31
|
+
expect(violations[0]?.excerpt).toBe("texte oublié");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("l'échappement délibéré {\"Acme\"} (donnée non traduisible) passe", () => {
|
|
35
|
+
const source = `export function Logo() { return <div>{"Acme"}</div>; }`;
|
|
36
|
+
expect(findHardcodedJsxText(source)).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("une expression dynamique ({order.code}) passe — c'est une donnée", () => {
|
|
40
|
+
const source = `export function Cell({ order }: { order: { code: string } }) { return <td>{order.code}</td>; }`;
|
|
41
|
+
expect(findHardcodedJsxText(source)).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("MUTATION : un attribut placeholder en dur est détecté", () => {
|
|
45
|
+
const source = `export function Field() { return <input placeholder="Votre email" />; }`;
|
|
46
|
+
const violations = findHardcodedJsxText(source);
|
|
47
|
+
expect(violations).toHaveLength(1);
|
|
48
|
+
expect(violations[0]?.rule).toContain("placeholder");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("un attribut machine (className, id) n'est jamais flaggé", () => {
|
|
52
|
+
const source = `export function Box() { return <div className="flex items-center" id="racine-app" />; }`;
|
|
53
|
+
expect(findHardcodedJsxText(source)).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("web-lint — valeurs Tailwind arbitraires", () => {
|
|
58
|
+
it("MUTATION : p-[13px] dans une className est détecté", () => {
|
|
59
|
+
const source = `export function Box() { return <div className="p-[13px] flex" />; }`;
|
|
60
|
+
const violations = findArbitraryTailwindValues(source);
|
|
61
|
+
expect(violations).toHaveLength(1);
|
|
62
|
+
expect(violations[0]?.excerpt).toBe("p-[13px]");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("MUTATION : une valeur arbitraire dans un template literal est détectée", () => {
|
|
66
|
+
const source = "export function Box({ active }: { active: boolean }) { return <div className={`flex ${active ? \"text-[#ff0000]\" : \"text-text\"}`} />; }";
|
|
67
|
+
const violations = findArbitraryTailwindValues(source);
|
|
68
|
+
expect(violations).toHaveLength(1);
|
|
69
|
+
expect(violations[0]?.excerpt).toBe("text-[#ff0000]");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("des utilitaires thémés (tokens) passent", () => {
|
|
73
|
+
const source = `export function Box() { return <div className="p-4 rounded-md bg-surface text-text-secondary tabular-nums" />; }`;
|
|
74
|
+
expect(findArbitraryTailwindValues(source)).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("web-lint — URL absolue d'API (D27)", () => {
|
|
79
|
+
it("MUTATION : un fetch sur une URL absolue vers /api est détecté", () => {
|
|
80
|
+
const source = `export function useOrders() { return fetch("http://localhost:3000/api/orders"); }`;
|
|
81
|
+
const violations = findAbsoluteApiUrls(source);
|
|
82
|
+
expect(violations).toHaveLength(1);
|
|
83
|
+
expect(violations[0]?.excerpt).toContain("http://localhost:3000/api");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("MUTATION : une URL absolue https dans un template literal est détectée", () => {
|
|
87
|
+
const source = "const base = `https://api.acme.example/api/${resource}`;";
|
|
88
|
+
expect(findAbsoluteApiUrls(source)).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("un appel relatif /api/... passe (la règle même de D27)", () => {
|
|
92
|
+
const source = `export function useOrders() { return fetch("/api/orders"); }`;
|
|
93
|
+
expect(findAbsoluteApiUrls(source)).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("une URL absolue SANS segment /api passe (lien documentaire légitime)", () => {
|
|
97
|
+
const source = `const docs = "https://example.com/api-reference-guide";`;
|
|
98
|
+
expect(findAbsoluteApiUrls(source)).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/web-lint.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Testable logic of the front part of the `pnpm lint` gate (the web
|
|
3
|
+
* shell spec's §Périmètre: "hardcoded string in JSX" and "arbitrary Tailwind
|
|
4
|
+
* value" both break the CI — D22: born with its mutation tests in
|
|
5
|
+
* web-lint.test.ts). Built on the TypeScript AST (not regexes): JSX text is
|
|
6
|
+
* only legitimate inside a <Trans> element (Lingui macro — fr is the source
|
|
7
|
+
* locale, so the French text IS the ICU source message), and human-facing
|
|
8
|
+
* attributes must go through the t`` macro.
|
|
9
|
+
* Deliberate escape hatch, documented: a string EXPRESSION ({"Acme"}) is not
|
|
10
|
+
* flagged — it marks non-translatable data (brand names, endonyms) on purpose.
|
|
11
|
+
*/
|
|
12
|
+
import ts from "typescript";
|
|
13
|
+
|
|
14
|
+
export interface WebLintViolation {
|
|
15
|
+
line: number;
|
|
16
|
+
rule: string;
|
|
17
|
+
excerpt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Attributes read by humans; everything else (className, id, to…) is machine-facing. */
|
|
21
|
+
const HUMAN_FACING_ATTRIBUTES = new Set(["placeholder", "title", "alt", "aria-label"]);
|
|
22
|
+
|
|
23
|
+
/** Two letters or more (any script) = a word a human will read. */
|
|
24
|
+
const HUMAN_TEXT = /\p{L}{2}/u;
|
|
25
|
+
|
|
26
|
+
/** Tailwind arbitrary value: `p-[13px]`, `text-[#0f0]`… (banned — tokens only). */
|
|
27
|
+
const ARBITRARY_TAILWIND_VALUE = /[\w-]+-\[[^\]\s]+\]/;
|
|
28
|
+
|
|
29
|
+
/** Absolute URL whose path enters /api — the front must never know the API
|
|
30
|
+
* origin (D27, AGENTS.md §Sécurité 8): it calls `/api/...` relatively. */
|
|
31
|
+
const ABSOLUTE_API_URL = /https?:\/\/[^\s"'`]*\/api([/?#]|$)/;
|
|
32
|
+
|
|
33
|
+
function lineOf(sourceFile: ts.SourceFile, node: ts.Node): number {
|
|
34
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isInsideTransElement(node: ts.Node): boolean {
|
|
38
|
+
let current: ts.Node | undefined = node.parent;
|
|
39
|
+
while (current !== undefined) {
|
|
40
|
+
if (ts.isJsxElement(current)) {
|
|
41
|
+
const tagName = current.openingElement.tagName.getText();
|
|
42
|
+
if (tagName === "Trans") return true;
|
|
43
|
+
}
|
|
44
|
+
current = current.parent;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** JSX text nodes (and human-facing string attributes) outside <Trans>. */
|
|
50
|
+
export function findHardcodedJsxText(source: string, fileName = "file.tsx"): WebLintViolation[] {
|
|
51
|
+
const sourceFile = ts.createSourceFile(
|
|
52
|
+
fileName,
|
|
53
|
+
source,
|
|
54
|
+
ts.ScriptTarget.Latest,
|
|
55
|
+
true,
|
|
56
|
+
ts.ScriptKind.TSX,
|
|
57
|
+
);
|
|
58
|
+
const violations: WebLintViolation[] = [];
|
|
59
|
+
|
|
60
|
+
function visit(node: ts.Node): void {
|
|
61
|
+
if (ts.isJsxText(node) && HUMAN_TEXT.test(node.text) && !isInsideTransElement(node)) {
|
|
62
|
+
violations.push({
|
|
63
|
+
line: lineOf(sourceFile, node),
|
|
64
|
+
rule: "hardcoded JSX text — wrap in <Trans> (Lingui) or move to data ({\"…\"} for brand names)",
|
|
65
|
+
excerpt: node.text.trim().slice(0, 60),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
ts.isJsxAttribute(node) &&
|
|
70
|
+
HUMAN_FACING_ATTRIBUTES.has(node.name.getText()) &&
|
|
71
|
+
node.initializer !== undefined &&
|
|
72
|
+
ts.isStringLiteral(node.initializer) &&
|
|
73
|
+
HUMAN_TEXT.test(node.initializer.text)
|
|
74
|
+
) {
|
|
75
|
+
violations.push({
|
|
76
|
+
line: lineOf(sourceFile, node),
|
|
77
|
+
rule: `hardcoded ${node.name.getText()} attribute — use the t\`\` macro (Lingui)`,
|
|
78
|
+
excerpt: node.initializer.text.slice(0, 60),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
ts.forEachChild(node, visit);
|
|
82
|
+
}
|
|
83
|
+
visit(sourceFile);
|
|
84
|
+
return violations;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Absolute API URLs in any string or template literal (D27: `/api` stays
|
|
88
|
+
* relative — an absolute URL would tie the front to an API origin the
|
|
89
|
+
* gateway exists to hide). Scans .ts AND .tsx: data-access files are where
|
|
90
|
+
* such URLs appear first. */
|
|
91
|
+
export function findAbsoluteApiUrls(source: string, fileName = "file.ts"): WebLintViolation[] {
|
|
92
|
+
const sourceFile = ts.createSourceFile(
|
|
93
|
+
fileName,
|
|
94
|
+
source,
|
|
95
|
+
ts.ScriptTarget.Latest,
|
|
96
|
+
true,
|
|
97
|
+
fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
98
|
+
);
|
|
99
|
+
const violations: WebLintViolation[] = [];
|
|
100
|
+
|
|
101
|
+
function visit(node: ts.Node): void {
|
|
102
|
+
if (
|
|
103
|
+
ts.isStringLiteral(node) ||
|
|
104
|
+
ts.isNoSubstitutionTemplateLiteral(node) ||
|
|
105
|
+
ts.isTemplateHead(node) ||
|
|
106
|
+
ts.isTemplateMiddle(node) ||
|
|
107
|
+
ts.isTemplateTail(node)
|
|
108
|
+
) {
|
|
109
|
+
const match = ABSOLUTE_API_URL.exec(node.text);
|
|
110
|
+
if (match !== null) {
|
|
111
|
+
violations.push({
|
|
112
|
+
line: lineOf(sourceFile, node),
|
|
113
|
+
rule: "absolute API URL — the front calls /api/... RELATIVELY, it must not know the API origin (D27)",
|
|
114
|
+
excerpt: match[0].slice(0, 60),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
ts.forEachChild(node, visit);
|
|
119
|
+
}
|
|
120
|
+
visit(sourceFile);
|
|
121
|
+
return violations;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Arbitrary Tailwind values in className strings/templates (tokens.json only). */
|
|
125
|
+
export function findArbitraryTailwindValues(
|
|
126
|
+
source: string,
|
|
127
|
+
fileName = "file.tsx",
|
|
128
|
+
): WebLintViolation[] {
|
|
129
|
+
const sourceFile = ts.createSourceFile(
|
|
130
|
+
fileName,
|
|
131
|
+
source,
|
|
132
|
+
ts.ScriptTarget.Latest,
|
|
133
|
+
true,
|
|
134
|
+
ts.ScriptKind.TSX,
|
|
135
|
+
);
|
|
136
|
+
const violations: WebLintViolation[] = [];
|
|
137
|
+
|
|
138
|
+
function checkText(text: string, node: ts.Node): void {
|
|
139
|
+
const match = ARBITRARY_TAILWIND_VALUE.exec(text);
|
|
140
|
+
if (match !== null) {
|
|
141
|
+
violations.push({
|
|
142
|
+
line: lineOf(sourceFile, node),
|
|
143
|
+
rule: "arbitrary Tailwind value — use a token from design/tokens.json (generated theme)",
|
|
144
|
+
excerpt: match[0],
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function visit(node: ts.Node): void {
|
|
150
|
+
if (ts.isJsxAttribute(node) && node.name.getText() === "className" && node.initializer !== undefined) {
|
|
151
|
+
const initializer = node.initializer;
|
|
152
|
+
if (ts.isStringLiteral(initializer)) {
|
|
153
|
+
checkText(initializer.text, initializer);
|
|
154
|
+
} else if (ts.isJsxExpression(initializer) && initializer.expression !== undefined) {
|
|
155
|
+
// Covers template literals and conditional class expressions: every
|
|
156
|
+
// string-ish literal below the className expression is checked.
|
|
157
|
+
const scanLiterals = (expressionNode: ts.Node): void => {
|
|
158
|
+
if (
|
|
159
|
+
ts.isStringLiteral(expressionNode) ||
|
|
160
|
+
ts.isNoSubstitutionTemplateLiteral(expressionNode) ||
|
|
161
|
+
ts.isTemplateHead(expressionNode) ||
|
|
162
|
+
ts.isTemplateMiddle(expressionNode) ||
|
|
163
|
+
ts.isTemplateTail(expressionNode)
|
|
164
|
+
) {
|
|
165
|
+
checkText(expressionNode.text, expressionNode);
|
|
166
|
+
}
|
|
167
|
+
ts.forEachChild(expressionNode, scanLiterals);
|
|
168
|
+
};
|
|
169
|
+
scanLiterals(initializer.expression);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
ts.forEachChild(node, visit);
|
|
173
|
+
}
|
|
174
|
+
visit(sourceFile);
|
|
175
|
+
return violations;
|
|
176
|
+
}
|