@ogygie/core 0.11.1 → 0.12.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/README.md +1 -1
- package/package.json +1 -1
- package/src/bundle-budget.test.ts +1 -1
- package/src/bundle-budget.ts +2 -1
- package/src/contract-purity.test.ts +2 -1
- package/src/contract-purity.ts +4 -3
- package/src/decision-refs.ts +1 -1
- package/src/manifest-gateway.ts +2 -2
- package/src/manifest.schema.test.ts +5 -4
- package/src/module-purity.test.ts +1 -1
- package/src/shared-tables.test.ts +1 -1
- package/src/shared-tables.ts +2 -1
- package/src/spec.schema.test.ts +3 -3
- package/src/spec.schema.ts +11 -9
- package/src/vocabulary-audit.test.ts +99 -0
- package/src/vocabulary-audit.ts +31 -0
- package/src/vocabulary.ts +24 -0
- package/src/web-lint.test.ts +4 -3
- package/src/web-lint.ts +4 -4
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ core tel quel.
|
|
|
58
58
|
4. **Zod aux frontières.** Toute entrée/sortie a un schéma Zod, source
|
|
59
59
|
unique de vérité (validation runtime + type inféré + doc) ; le graphe
|
|
60
60
|
d'un `schemas.ts` n'autorise par défaut QUE `zod` (`contract-purity`,
|
|
61
|
-
|
|
61
|
+
AGENTS.md §Structure — l'exception des CONTRATS).
|
|
62
62
|
5. **Une devise de travail par tenant.** L'argent est un entier en centimes
|
|
63
63
|
+ code ISO 4217 ; aucune conversion de change ne traverse le core. Les
|
|
64
64
|
futurs helpers monétaires naîtront ici sous cette hypothèse.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Mutation tests of the bundle-budget gate logic (
|
|
2
|
+
* @intent Mutation tests of the bundle-budget gate logic (AGENTS.md workflow 7: a known
|
|
3
3
|
* violation must be DETECTED, not just the happy path).
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
package/src/bundle-budget.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Testable logic of the CI gate `ogygie:verify-bundle` (
|
|
2
|
+
* @intent Testable logic of the CI gate `ogygie:verify-bundle` (AGENTS.md
|
|
3
|
+
* workflow 7: a gate is
|
|
3
4
|
* born with its mutation test — see bundle-budget.test.ts). PURE on purpose
|
|
4
5
|
* (no node imports — the core tsconfig has no node types): the script
|
|
5
6
|
* scripts/verify-bundle.ts reads the files and gzips them, this module decides
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Proves the transitive contract gate AND its mutations (
|
|
2
|
+
* @intent Proves the transitive contract gate AND its mutations (AGENTS.md
|
|
3
|
+
* workflow 7): each
|
|
3
4
|
* case below is a way the server has come back — or could come back — into a
|
|
4
5
|
* contract graph, and the audit must catch every one of them without crying
|
|
5
6
|
* wolf on the erased forms. The keystone case is the historical incident
|
package/src/contract-purity.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent TRANSITIVE purity of the contract entry points (
|
|
2
|
+
* @intent TRANSITIVE purity of the contract entry points (AGENTS.md
|
|
3
|
+
* §Structure, the CONTRACTS exception):
|
|
3
4
|
* from a feature's `schemas.ts`, no module with a runtime effect may be
|
|
4
5
|
* REACHABLE — neither directly nor through any chain of intermediaries.
|
|
5
6
|
*
|
|
@@ -18,12 +19,12 @@
|
|
|
18
19
|
* (AGENTS.md §Structure), the exact edge of the incident above.
|
|
19
20
|
* Plus one honesty rule: a module the walk cannot READ is reported, never
|
|
20
21
|
* skipped — a gate that silently swallows what it does not understand is
|
|
21
|
-
* worse than no gate
|
|
22
|
+
* worse than no gate.
|
|
22
23
|
*
|
|
23
24
|
* Generic tooling, hence the core (D10 amended): it knows the framework's
|
|
24
25
|
* vocabulary (apps, features, schemas.ts, index.ts) and not one word of the
|
|
25
26
|
* product's domain. The filesystem is INJECTED (`readModule`), so the walk is
|
|
26
|
-
* a pure function, tested in memory with its mutations (
|
|
27
|
+
* a pure function, tested in memory with its mutations (AGENTS.md workflow 7).
|
|
27
28
|
*/
|
|
28
29
|
import { parseModuleImports } from "./module-purity.js";
|
|
29
30
|
|
package/src/decision-refs.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* eliminate — so it breaks the build instead.
|
|
10
10
|
*
|
|
11
11
|
* These functions are pure (source text in, findings out) so the negative tests
|
|
12
|
-
* required by
|
|
12
|
+
* required by AGENTS.md workflow 7 can feed them known violations directly;
|
|
13
13
|
* scripts/verify-specs.ts only reports what they return.
|
|
14
14
|
*/
|
|
15
15
|
|
package/src/manifest-gateway.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent GENERIC manifest-driven gateway factory
|
|
2
|
+
* @intent GENERIC manifest-driven gateway factory, framework side: this
|
|
3
3
|
* module knows ZERO product vocabulary (D10 amended — generic tooling lives in
|
|
4
4
|
* the core), and the vocabulary test next to it proves it. It derives a pure
|
|
5
5
|
* relay from an Ogygie manifest: every declared route becomes a typed relay to
|
|
@@ -382,7 +382,7 @@ export async function createManifestGateway(
|
|
|
382
382
|
file: null,
|
|
383
383
|
cause: `${request.method} ${request.url} is not declared in the manifest`,
|
|
384
384
|
suggestedFix:
|
|
385
|
-
"Declare the route in ogygie.manifest.json — an undeclared route is unreachable by design
|
|
385
|
+
"Declare the route in ogygie.manifest.json — an undeclared route is unreachable by design.",
|
|
386
386
|
}),
|
|
387
387
|
);
|
|
388
388
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Mutation tests of the manifest front-feature extension (
|
|
2
|
+
* @intent Mutation tests of the manifest front-feature extension (AGENTS.md
|
|
3
|
+
* workflow 7: the
|
|
3
4
|
* gate evolution is born with its negative tests). The api-feature path is
|
|
4
5
|
* already covered end-to-end by verify-manifest in CI.
|
|
5
6
|
*/
|
|
@@ -140,7 +141,7 @@ describe("manifest — invalidations croisées des routes (spec manifest-client
|
|
|
140
141
|
});
|
|
141
142
|
});
|
|
142
143
|
|
|
143
|
-
// Gateway extension
|
|
144
|
+
// Gateway extension: the gateway app type exists, and
|
|
144
145
|
// a gateway feature — like a front one — OWNS no routes and no tables (it
|
|
145
146
|
// RELAYS the api's declared routes, it never declares its own).
|
|
146
147
|
function manifestWithGatewayFeature(feature: Record<string, unknown>) {
|
|
@@ -160,7 +161,7 @@ function manifestWithGatewayFeature(feature: Record<string, unknown>) {
|
|
|
160
161
|
return base;
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
describe("manifest — app gateway
|
|
164
|
+
describe("manifest — app gateway", () => {
|
|
164
165
|
it("une feature gateway valide (app: gateway, routes/dbTables vides) est acceptée", () => {
|
|
165
166
|
const parsed = OgygieManifestSchema.safeParse(manifestWithGatewayFeature({}));
|
|
166
167
|
expect(parsed.success).toBe(true);
|
|
@@ -317,7 +318,7 @@ describe("manifest — ogygieVersion et bloc framework (spec ogygie-standalone-r
|
|
|
317
318
|
});
|
|
318
319
|
});
|
|
319
320
|
|
|
320
|
-
describe("manifest — app gateway, suite
|
|
321
|
+
describe("manifest — app gateway, suite", () => {
|
|
321
322
|
it("MUTATION (bis) : une feature gateway qui déclare une table DB est refusée", () => {
|
|
322
323
|
const parsed = OgygieManifestSchema.safeParse(
|
|
323
324
|
manifestWithGatewayFeature({ dbTables: ["orders"] }),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Proves the purity checker AND its mutations (
|
|
2
|
+
* @intent Proves the purity checker AND its mutations (AGENTS.md workflow 7): a
|
|
3
3
|
* gate is only worth its negative test. Every case below is a source a pure
|
|
4
4
|
* module could plausibly drift into — the checker must catch each one, and
|
|
5
5
|
* must NOT cry wolf on the erased forms.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Mutation tests of the shared-table gates (
|
|
2
|
+
* @intent Mutation tests of the shared-table gates (AGENTS.md workflow 7: every
|
|
3
3
|
* gate is born with its negative test). The shared-table exception is a hole
|
|
4
4
|
* in the multi-tenant rail — each way of abusing it is fed here as a KNOWN
|
|
5
5
|
* VIOLATION and must be DETECTED, never silently tolerated.
|
package/src/shared-tables.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* the raw-db ban unenforceable by inspection.
|
|
15
15
|
*
|
|
16
16
|
* These functions are pure (source text in, findings out) so that the negative
|
|
17
|
-
* tests required by
|
|
17
|
+
* mutation tests required by AGENTS.md workflow 7 can feed them known
|
|
18
|
+
* violations directly —
|
|
18
19
|
* scripts/lint-interdits.ts and scripts/verify-manifest.ts only report what
|
|
19
20
|
* they return. The module is GENERIC infrastructure (D10 amended): it speaks
|
|
20
21
|
* "tenant", never a word of the product's domain.
|
package/src/spec.schema.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Mutation tests of the spec-frontmatter format (
|
|
2
|
+
* @intent Mutation tests of the spec-frontmatter format (AGENTS.md workflow 7:
|
|
3
3
|
* every gate is born — and evolves — with its negative test). Each test feeds
|
|
4
4
|
* the parser a KNOWN VIOLATION and requires DETECTION, never a silent
|
|
5
5
|
* default: the three traps consigned in FAILURES.md (2026-07-30, session 7)
|
|
@@ -92,13 +92,13 @@ status: validated
|
|
|
92
92
|
|
|
93
93
|
it("MUTATION — une virgule INTERNE à une entrée citée ne casse plus l'entrée en fragments (split conscient des guillemets)", () => {
|
|
94
94
|
const parsed = parseSpecFrontmatter(
|
|
95
|
-
'deferred: ["
|
|
95
|
+
'deferred: ["ARCHIVE_EXPORT (bulk export of archived records) blockedBy:archive"]',
|
|
96
96
|
);
|
|
97
97
|
|
|
98
98
|
expect(parsed.problems).toEqual([]);
|
|
99
99
|
// ONE element, comma preserved — the old split produced two broken shards.
|
|
100
100
|
expect(parsed.value["deferred"]).toEqual([
|
|
101
|
-
"
|
|
101
|
+
"ARCHIVE_EXPORT (bulk export of archived records) blockedBy:archive",
|
|
102
102
|
]);
|
|
103
103
|
// …and that whole entry satisfies the DeferredBehaviorSchema regex.
|
|
104
104
|
const validated = SpecFrontmatterSchema.safeParse({
|
package/src/spec.schema.ts
CHANGED
|
@@ -4,18 +4,20 @@
|
|
|
4
4
|
* machine-readable frontmatter + mandatory canonical sections. The CI
|
|
5
5
|
* validator (scripts/verify-specs.ts) checks the frontmatter AND the sections;
|
|
6
6
|
* this module owns the restricted frontmatter format — scalars and arrays of
|
|
7
|
-
* scalars, inline or multiline
|
|
7
|
+
* scalars, inline or multiline — through parseSpecFrontmatter, which REFUSES what it
|
|
8
8
|
* cannot read instead of silently swallowing it (a multiline array once read
|
|
9
9
|
* as `deferred: []` without a sound — FAILURES.md 2026-07-30, DECISIONS.md
|
|
10
|
-
*
|
|
10
|
+
* AGENTS.md workflow 7: every gate is born with its mutation test, see
|
|
11
|
+
* spec.schema.test.ts).
|
|
11
12
|
* Section titles stay FRENCH on purpose: specs are human-facing documents
|
|
12
|
-
* (
|
|
13
|
+
* (AGENTS.md principle 8 — humans of the project read and validate them in
|
|
14
|
+
* French, only the machine-readable half is English).
|
|
13
15
|
*/
|
|
14
16
|
import { z } from "zod";
|
|
15
17
|
import { FeatureNameSchema } from "./manifest.schema.js";
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
|
-
* One deferred behavior
|
|
20
|
+
* One deferred behavior: a behavior the spec describes but
|
|
19
21
|
* whose implementation is blocked by a feature that does not exist yet.
|
|
20
22
|
* Format: "<stable error code or short label> blockedBy:<feature-name>".
|
|
21
23
|
* The frontmatter parser only reads arrays of STRINGS (inline or multiline),
|
|
@@ -40,7 +42,7 @@ export const SpecFrontmatterSchema = z.object({
|
|
|
40
42
|
deferred: z
|
|
41
43
|
.array(DeferredBehaviorSchema)
|
|
42
44
|
.default([])
|
|
43
|
-
.describe("Behaviors deferred to a later feature
|
|
45
|
+
.describe("Behaviors deferred to a later feature, blocked by name"),
|
|
44
46
|
});
|
|
45
47
|
export type SpecFrontmatter = z.infer<typeof SpecFrontmatterSchema>;
|
|
46
48
|
|
|
@@ -62,7 +64,7 @@ export const SECTIONS_SPEC_OBLIGATOIRES = [
|
|
|
62
64
|
* - status: draft → no implementation may exist
|
|
63
65
|
* - status: validated → implementation is allowed
|
|
64
66
|
* - status: implemented → the feature exists in the manifest with status "delivered"
|
|
65
|
-
* AND `deferred` is empty (
|
|
67
|
+
* AND `deferred` is empty (nothing left pending)
|
|
66
68
|
*/
|
|
67
69
|
|
|
68
70
|
// ----------------------------------------------------------- format parser
|
|
@@ -123,7 +125,7 @@ function unquote(raw: string, location: string, problems: string[]): string | nu
|
|
|
123
125
|
* Everything it cannot read still becomes a problem instead of a silent
|
|
124
126
|
* default: an array never closed before the end of the frontmatter, a spilled
|
|
125
127
|
* element line OUTSIDE any array, an unbalanced quote — each once made a spec
|
|
126
|
-
* lie by omission (FAILURES.md 2026-07-30
|
|
128
|
+
* lie by omission (FAILURES.md 2026-07-30).
|
|
127
129
|
*/
|
|
128
130
|
export function parseSpecFrontmatter(text: string): ParsedSpecFrontmatter {
|
|
129
131
|
const value: Record<string, unknown> = {};
|
|
@@ -135,7 +137,7 @@ export function parseSpecFrontmatter(text: string): ParsedSpecFrontmatter {
|
|
|
135
137
|
const colonIndex = trimmed.indexOf(":");
|
|
136
138
|
if (colonIndex === -1 || trimmed.startsWith('"') || trimmed.startsWith("'")) {
|
|
137
139
|
problems.push(
|
|
138
|
-
`unparseable line ${JSON.stringify(trimmed)}: expected \`key: value\` — a spilled array element outside any array? (scalars and arrays of scalars only
|
|
140
|
+
`unparseable line ${JSON.stringify(trimmed)}: expected \`key: value\` — a spilled array element outside any array? (scalars and arrays of scalars only)`,
|
|
139
141
|
);
|
|
140
142
|
continue;
|
|
141
143
|
}
|
|
@@ -153,7 +155,7 @@ export function parseSpecFrontmatter(text: string): ParsedSpecFrontmatter {
|
|
|
153
155
|
}
|
|
154
156
|
if (!closed) {
|
|
155
157
|
problems.push(
|
|
156
|
-
`key "${key}": array opened with "[" and never closed with "]" before the end of the frontmatter — a truncated array would be silently read as empty, hence refused
|
|
158
|
+
`key "${key}": array opened with "[" and never closed with "]" before the end of the frontmatter — a truncated array would be silently read as empty, hence refused`,
|
|
157
159
|
);
|
|
158
160
|
continue;
|
|
159
161
|
}
|
|
@@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest";
|
|
|
9
9
|
import {
|
|
10
10
|
auditProductVocabulary,
|
|
11
11
|
canonicalByAlias,
|
|
12
|
+
derogatedFeatureIdsOf,
|
|
12
13
|
vocabularyRootsOf,
|
|
13
14
|
} from "./vocabulary-audit.js";
|
|
14
15
|
|
|
@@ -198,3 +199,101 @@ describe("dérivations du bloc vocabulary", () => {
|
|
|
198
199
|
]);
|
|
199
200
|
});
|
|
200
201
|
});
|
|
202
|
+
|
|
203
|
+
describe("auditProductVocabulary — B3 : la dérogation des ids de features (D16)", () => {
|
|
204
|
+
/** The product's features are named `widgets` — the very forbidden alias. */
|
|
205
|
+
const FEATURE_IDS = ["widgets", "gadget-catalog"];
|
|
206
|
+
|
|
207
|
+
it("ÉTANT DONNÉ un id de feature qui EST un alias interdit QUAND le code en dérive ses identifiants ALORS rien n'est signalé", () => {
|
|
208
|
+
const violations = auditProductVocabulary({
|
|
209
|
+
vocabulary: VOCABULARY,
|
|
210
|
+
sourceFiles: [
|
|
211
|
+
{
|
|
212
|
+
path: "apps/api/src/features/widgets/routes.ts",
|
|
213
|
+
content: [
|
|
214
|
+
'import { listWidgets } from "../features/widgets/service.js";',
|
|
215
|
+
"export function registerWidgetsRoutes() {}",
|
|
216
|
+
].join("\n"),
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
governedFiles: [],
|
|
220
|
+
featureIds: FEATURE_IDS,
|
|
221
|
+
});
|
|
222
|
+
expect(violations).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("MUTATION — sans la dérogation, ces mêmes identifiants dérivés sont TOUS signalés", () => {
|
|
226
|
+
const violations = auditProductVocabulary({
|
|
227
|
+
vocabulary: VOCABULARY,
|
|
228
|
+
sourceFiles: [
|
|
229
|
+
{
|
|
230
|
+
path: "apps/api/src/features/widgets/routes.ts",
|
|
231
|
+
content: [
|
|
232
|
+
'import { listWidgets } from "../features/widgets/service.js";',
|
|
233
|
+
"export function registerWidgetsRoutes() {}",
|
|
234
|
+
].join("\n"),
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
governedFiles: [],
|
|
238
|
+
});
|
|
239
|
+
expect(violations.map((violation) => violation.match.identifier).sort()).toEqual([
|
|
240
|
+
"listWidgets",
|
|
241
|
+
"registerWidgetsRoutes",
|
|
242
|
+
"widgets",
|
|
243
|
+
]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("la dérogation couvre la DÉRIVATION, jamais un nom seulement préfixé par la racine", () => {
|
|
247
|
+
const violations = auditProductVocabulary({
|
|
248
|
+
vocabulary: VOCABULARY,
|
|
249
|
+
sourceFiles: [
|
|
250
|
+
{ path: "apps/api/src/features/widgets/service.ts", content: "const widgetCode = 1;" },
|
|
251
|
+
],
|
|
252
|
+
governedFiles: [],
|
|
253
|
+
featureIds: FEATURE_IDS,
|
|
254
|
+
});
|
|
255
|
+
// `widgetCode` carries the root but NOT the whole feature id `widgets`.
|
|
256
|
+
expect(violations).toHaveLength(1);
|
|
257
|
+
expect(violations[0]?.match.identifier).toBe("widgetCode");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("un id de feature multi-segments couvre ses dérivés, et lui seul", () => {
|
|
261
|
+
const violations = auditProductVocabulary({
|
|
262
|
+
vocabulary: VOCABULARY,
|
|
263
|
+
sourceFiles: [
|
|
264
|
+
{
|
|
265
|
+
path: "apps/api/src/features/gadget-catalog/service.ts",
|
|
266
|
+
content: ["const gadgetCatalogRows = [];", "const gadgetCatalogue = [];"].join("\n"),
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
governedFiles: [],
|
|
270
|
+
featureIds: FEATURE_IDS,
|
|
271
|
+
});
|
|
272
|
+
expect(violations.map((violation) => violation.match.identifier)).toEqual([
|
|
273
|
+
"gadgetCatalogue",
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("la dérogation ne touche PAS la règle des fichiers gouvernés (B4)", () => {
|
|
278
|
+
const violations = auditProductVocabulary({
|
|
279
|
+
vocabulary: VOCABULARY,
|
|
280
|
+
sourceFiles: [],
|
|
281
|
+
governedFiles: [{ path: "AGENTS.md", content: "le gadget du produit" }],
|
|
282
|
+
featureIds: ["gadget"],
|
|
283
|
+
});
|
|
284
|
+
expect(violations).toHaveLength(1);
|
|
285
|
+
expect(violations[0]?.kind).toBe("GOVERNED_FILE");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("derogatedFeatureIdsOf — ce que la dérogation excuse VRAIMENT", () => {
|
|
290
|
+
it("ne retient que les ids de features qui portent un alias interdit", () => {
|
|
291
|
+
expect(
|
|
292
|
+
derogatedFeatureIdsOf(VOCABULARY, ["widgets", "invoices", "gadget-catalog"]),
|
|
293
|
+
).toEqual(["widgets", "gadget-catalog"]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("sans vocabulaire déclaré, aucune dérogation à annoncer", () => {
|
|
297
|
+
expect(derogatedFeatureIdsOf(undefined, ["widgets"])).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
});
|
package/src/vocabulary-audit.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
findProductVocabulary,
|
|
31
31
|
stripComments,
|
|
32
32
|
stripProseStrings,
|
|
33
|
+
tokenCarriesExactName,
|
|
33
34
|
type VocabularyMatch,
|
|
34
35
|
} from "./vocabulary.js";
|
|
35
36
|
|
|
@@ -61,6 +62,14 @@ export interface VocabularyAuditInput {
|
|
|
61
62
|
readonly sourceFiles: readonly ScannedFile[];
|
|
62
63
|
/** Framework-governed files as they exist in the project (B4). */
|
|
63
64
|
readonly governedFiles: readonly ScannedFile[];
|
|
65
|
+
/**
|
|
66
|
+
* The manifest's feature ids. Identifiers DERIVED from one of them are not
|
|
67
|
+
* judged against the forbidden aliases (B3 derogation, D16): a feature id
|
|
68
|
+
* names a document and follows the project's language, while the code
|
|
69
|
+
* derives its folders, import paths and function names from it. Absent or
|
|
70
|
+
* empty, nothing is excused.
|
|
71
|
+
*/
|
|
72
|
+
readonly featureIds?: readonly string[];
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/**
|
|
@@ -81,6 +90,23 @@ export function canonicalByAlias(vocabulary: ProductVocabulary): ReadonlyMap<str
|
|
|
81
90
|
return byAlias;
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The feature ids the derogation actually covers (D16): those whose own name
|
|
95
|
+
* carries a forbidden root. A feature id that collides with nothing excuses
|
|
96
|
+
* nothing, and must NOT be announced — a derogation list padded with harmless
|
|
97
|
+
* entries is a list nobody reads, which is how a derogation gets forgotten.
|
|
98
|
+
*/
|
|
99
|
+
export function derogatedFeatureIdsOf(
|
|
100
|
+
vocabulary: ProductVocabulary | undefined,
|
|
101
|
+
featureIds: readonly string[],
|
|
102
|
+
): readonly string[] {
|
|
103
|
+
if (vocabulary === undefined || vocabulary.entries.length === 0) return [];
|
|
104
|
+
const forbiddenAliases = [...canonicalByAlias(vocabulary).keys()];
|
|
105
|
+
return featureIds.filter(
|
|
106
|
+
(featureId) => findProductVocabulary(featureId, forbiddenAliases).length > 0,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
export function auditProductVocabulary(
|
|
85
111
|
input: VocabularyAuditInput,
|
|
86
112
|
): readonly VocabularyViolation[] {
|
|
@@ -92,9 +118,14 @@ export function auditProductVocabulary(
|
|
|
92
118
|
const canonicalTerms = vocabulary.entries.map((entry) => entry.canonical);
|
|
93
119
|
const violations: VocabularyViolation[] = [];
|
|
94
120
|
|
|
121
|
+
const featureIds = input.featureIds ?? [];
|
|
122
|
+
const derivesFromAFeatureId = (identifier: string): boolean =>
|
|
123
|
+
featureIds.some((featureId) => tokenCarriesExactName(identifier, featureId));
|
|
124
|
+
|
|
95
125
|
for (const file of input.sourceFiles) {
|
|
96
126
|
const judged = stripProseStrings(stripComments(file.content));
|
|
97
127
|
for (const match of findProductVocabulary(judged, forbiddenAliases)) {
|
|
128
|
+
if (derivesFromAFeatureId(match.identifier)) continue;
|
|
98
129
|
violations.push({
|
|
99
130
|
kind: "FORBIDDEN_ALIAS",
|
|
100
131
|
filePath: file.path,
|
package/src/vocabulary.ts
CHANGED
|
@@ -75,6 +75,30 @@ function tokenCarriesRoot(
|
|
|
75
75
|
return false;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Does this token carry that whole name as an EXACT run of segments? Unlike
|
|
80
|
+
* `tokenCarriesRoot`, the last segment must match in full: this serves the
|
|
81
|
+
* feature-id derogation (D16), where the point is to excuse what DERIVES from
|
|
82
|
+
* a declared name — `registerOrdersRoutes` for the feature `orders` — and
|
|
83
|
+
* never a name that merely starts the same way (`orderCode` stays judged).
|
|
84
|
+
*/
|
|
85
|
+
export function tokenCarriesExactName(token: string, name: string): boolean {
|
|
86
|
+
const tokenSegments = segmentsOfIdentifier(token);
|
|
87
|
+
const nameSegments = segmentsOfIdentifier(name);
|
|
88
|
+
if (nameSegments.length === 0) return false;
|
|
89
|
+
for (let start = 0; start + nameSegments.length <= tokenSegments.length; start += 1) {
|
|
90
|
+
let allMatch = true;
|
|
91
|
+
for (let offset = 0; offset < nameSegments.length; offset += 1) {
|
|
92
|
+
if (tokenSegments[start + offset] !== nameSegments[offset]) {
|
|
93
|
+
allMatch = false;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (allMatch) return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
/**
|
|
79
103
|
* Every forbidden root the text names, in order of appearance, deduplicated by
|
|
80
104
|
* (root, identifier): one finding per distinct faulty identifier, however many
|
package/src/web-lint.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Mutation tests of the web lint gate (
|
|
2
|
+
* @intent Mutation tests of the web lint gate (AGENTS.md workflow 7): each
|
|
3
|
+
* KNOWN violation is
|
|
3
4
|
* proven detected, and each legitimate pattern is proven accepted.
|
|
4
5
|
*/
|
|
5
6
|
import { describe, expect, it } from "vitest";
|
|
@@ -75,7 +76,7 @@ describe("web-lint — valeurs Tailwind arbitraires", () => {
|
|
|
75
76
|
});
|
|
76
77
|
});
|
|
77
78
|
|
|
78
|
-
describe("web-lint — URL absolue d'API (
|
|
79
|
+
describe("web-lint — URL absolue d'API (AGENTS.md §Sécurité 8)", () => {
|
|
79
80
|
it("MUTATION : un fetch sur une URL absolue vers /api est détecté", () => {
|
|
80
81
|
const source = `export function useOrders() { return fetch("http://localhost:3000/api/orders"); }`;
|
|
81
82
|
const violations = findAbsoluteApiUrls(source);
|
|
@@ -88,7 +89,7 @@ describe("web-lint — URL absolue d'API (D27)", () => {
|
|
|
88
89
|
expect(findAbsoluteApiUrls(source)).toHaveLength(1);
|
|
89
90
|
});
|
|
90
91
|
|
|
91
|
-
it("un appel relatif /api/... passe (la règle même
|
|
92
|
+
it("un appel relatif /api/... passe (la règle même du §Sécurité 8)", () => {
|
|
92
93
|
const source = `export function useOrders() { return fetch("/api/orders"); }`;
|
|
93
94
|
expect(findAbsoluteApiUrls(source)).toHaveLength(0);
|
|
94
95
|
});
|
package/src/web-lint.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @intent Testable logic of the front part of the `pnpm lint` gate (the web
|
|
3
3
|
* shell spec's §Périmètre: "hardcoded string in JSX" and "arbitrary Tailwind
|
|
4
|
-
* value" both break the CI —
|
|
4
|
+
* value" both break the CI — born with its mutation tests in
|
|
5
5
|
* web-lint.test.ts). Built on the TypeScript AST (not regexes): JSX text is
|
|
6
6
|
* only legitimate inside a <Trans> element (Lingui macro — fr is the source
|
|
7
7
|
* locale, so the French text IS the ICU source message), and human-facing
|
|
@@ -27,7 +27,7 @@ const HUMAN_TEXT = /\p{L}{2}/u;
|
|
|
27
27
|
const ARBITRARY_TAILWIND_VALUE = /[\w-]+-\[[^\]\s]+\]/;
|
|
28
28
|
|
|
29
29
|
/** Absolute URL whose path enters /api — the front must never know the API
|
|
30
|
-
* origin (
|
|
30
|
+
* origin (AGENTS.md §Sécurité 8): it calls `/api/...` relatively. */
|
|
31
31
|
const ABSOLUTE_API_URL = /https?:\/\/[^\s"'`]*\/api([/?#]|$)/;
|
|
32
32
|
|
|
33
33
|
function lineOf(sourceFile: ts.SourceFile, node: ts.Node): number {
|
|
@@ -84,7 +84,7 @@ export function findHardcodedJsxText(source: string, fileName = "file.tsx"): Web
|
|
|
84
84
|
return violations;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/** Absolute API URLs in any string or template literal (
|
|
87
|
+
/** Absolute API URLs in any string or template literal (`/api` stays
|
|
88
88
|
* relative — an absolute URL would tie the front to an API origin the
|
|
89
89
|
* gateway exists to hide). Scans .ts AND .tsx: data-access files are where
|
|
90
90
|
* such URLs appear first. */
|
|
@@ -110,7 +110,7 @@ export function findAbsoluteApiUrls(source: string, fileName = "file.ts"): WebLi
|
|
|
110
110
|
if (match !== null) {
|
|
111
111
|
violations.push({
|
|
112
112
|
line: lineOf(sourceFile, node),
|
|
113
|
-
rule: "absolute API URL — the front calls /api/... RELATIVELY, it must not know the API origin (
|
|
113
|
+
rule: "absolute API URL — the front calls /api/... RELATIVELY, it must not know the API origin (AGENTS.md §Sécurité 8)",
|
|
114
114
|
excerpt: match[0].slice(0, 60),
|
|
115
115
|
});
|
|
116
116
|
}
|