@ogygie/core 0.5.4 → 0.6.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 -0
- package/package.json +2 -1
- package/src/manifest-owners.test.ts +79 -0
- package/src/manifest-owners.ts +72 -0
- package/src/manifest.schema.test.ts +83 -0
- package/src/manifest.schema.ts +92 -2
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ dans `apps/`.
|
|
|
9
9
|
| Export | Rôle |
|
|
10
10
|
|---|---|
|
|
11
11
|
| `@ogygie/core/errors` | Format `OgygieError` (code stable, `file`, `cause`, `suggestedFix`) |
|
|
12
|
+
| `@ogygie/core/manifest-owners` | Quelle entrée de la carte possède une spec — feature ou package du produit |
|
|
12
13
|
| `@ogygie/core/manifest` | Schéma du manifeste `ogygie.manifest.json` |
|
|
13
14
|
| `@ogygie/core/spec` | Frontmatter des specs (`parseSpecFrontmatter`) et sections canoniques |
|
|
14
15
|
| `@ogygie/core/db` · `/clock` · `/tenant` · `/test-kit` | Accès DB, horloge injectable, façade multi-tenant, kit de test |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"exports": {
|
|
16
16
|
"./manifest": "./src/manifest.schema.ts",
|
|
17
|
+
"./manifest-owners": "./src/manifest-owners.ts",
|
|
17
18
|
"./vocabulary": "./src/vocabulary.ts",
|
|
18
19
|
"./vocabulary-audit": "./src/vocabulary-audit.ts",
|
|
19
20
|
"./web-lint": "./src/web-lint.ts",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Mutation tests of the spec-lifecycle lookup (spec manifest-packages
|
|
3
|
+
* B2). The rule it serves is unchanged — a spec claims `implemented` only when
|
|
4
|
+
* the map confirms — so what is proven here is that BOTH natures of entry can
|
|
5
|
+
* confirm, and that neither shadows the other.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { findSpecOwner } from "./manifest-owners.js";
|
|
9
|
+
import { OgygieManifestSchema } from "./manifest.schema.js";
|
|
10
|
+
|
|
11
|
+
function manifestWith(extra: Record<string, unknown>) {
|
|
12
|
+
return OgygieManifestSchema.parse({
|
|
13
|
+
version: 1,
|
|
14
|
+
product: { name: "acme", description: "SaaS générique servant de fixture aux tests du manifeste." },
|
|
15
|
+
apps: [{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" }],
|
|
16
|
+
features: [],
|
|
17
|
+
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
18
|
+
...extra,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const gadgetEngine = {
|
|
23
|
+
name: "gadget-engine",
|
|
24
|
+
path: "packages/gadget-engine",
|
|
25
|
+
intent: "Moteur pur : aucune I/O, aucune horloge, portable navigateur.",
|
|
26
|
+
spec: "specs/gadget-engine.spec.md",
|
|
27
|
+
dependsOn: [],
|
|
28
|
+
status: "delivered",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("findSpecOwner — feature ou package, la carte confirme les deux", () => {
|
|
32
|
+
it("ÉTANT DONNÉ une spec dont le code vit dans un PACKAGE ALORS la carte le confirme", () => {
|
|
33
|
+
const owner = findSpecOwner(manifestWith({ packages: [gadgetEngine] }), "gadget-engine");
|
|
34
|
+
expect(owner).toEqual({
|
|
35
|
+
kind: "package",
|
|
36
|
+
name: "gadget-engine",
|
|
37
|
+
status: "delivered",
|
|
38
|
+
folder: "packages/gadget-engine",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("une feature reste trouvée, et son dossier est joint au chemin de son app", () => {
|
|
43
|
+
const owner = findSpecOwner(
|
|
44
|
+
manifestWith({
|
|
45
|
+
features: [
|
|
46
|
+
{
|
|
47
|
+
name: "orders",
|
|
48
|
+
intent: "Feature d'exemple servant de fixture aux tests du manifeste.",
|
|
49
|
+
folder: "src/features/orders",
|
|
50
|
+
spec: "specs/orders.spec.md",
|
|
51
|
+
routes: [],
|
|
52
|
+
dbTables: [],
|
|
53
|
+
dependsOn: [],
|
|
54
|
+
status: "delivered",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
"orders",
|
|
59
|
+
);
|
|
60
|
+
expect(owner?.kind).toBe("feature");
|
|
61
|
+
expect(owner?.folder).toBe("apps/api/src/features/orders");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("une spec sans entrée dans la carte n'a AUCUN propriétaire — le refus reste dû", () => {
|
|
65
|
+
expect(findSpecOwner(manifestWith({}), "inconnue")).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("le statut du package est rendu tel quel : un package en cours ne vaut pas livré", () => {
|
|
69
|
+
const owner = findSpecOwner(
|
|
70
|
+
manifestWith({ packages: [{ ...gadgetEngine, status: "in-progress" }] }),
|
|
71
|
+
"gadget-engine",
|
|
72
|
+
);
|
|
73
|
+
expect(owner?.status).toBe("in-progress");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("un manifeste SANS bloc packages se comporte exactement comme avant", () => {
|
|
77
|
+
expect(findSpecOwner(manifestWith({}), "gadget-engine")).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Answers the ONE question the spec lifecycle rests on: which entry of
|
|
3
|
+
* the map owns this spec, and how far along is it?
|
|
4
|
+
*
|
|
5
|
+
* The rule itself does not change — a spec only claims `implemented` when the
|
|
6
|
+
* manifest confirms it. What changes is that the map now has two kinds of
|
|
7
|
+
* entry able to confirm: a feature living under an app, and a PRODUCT PACKAGE
|
|
8
|
+
* living outside every app (spec manifest-packages). Before that, a spec whose
|
|
9
|
+
* code sat in `packages/` could never be marked finished, and nothing said why
|
|
10
|
+
* — the gate answered `manifest status=absent` and suggested two fixes, both
|
|
11
|
+
* impossible.
|
|
12
|
+
*
|
|
13
|
+
* Lives in the core, not in the gate script, so the rule travels with the
|
|
14
|
+
* versioned package instead of with a copy each project keeps.
|
|
15
|
+
*/
|
|
16
|
+
import type { OgygieManifest } from "./manifest.schema.js";
|
|
17
|
+
|
|
18
|
+
export type SpecOwnerKind = "feature" | "package";
|
|
19
|
+
|
|
20
|
+
export interface SpecOwner {
|
|
21
|
+
kind: SpecOwnerKind;
|
|
22
|
+
name: string;
|
|
23
|
+
status: "skeleton" | "in-progress" | "delivered";
|
|
24
|
+
/**
|
|
25
|
+
* Folder holding the code, relative to the REPO root — already joined with
|
|
26
|
+
* the owning app's path for a feature, whose own `folder` is relative to it.
|
|
27
|
+
* Null when the map cannot say (a feature whose app is not declared).
|
|
28
|
+
*/
|
|
29
|
+
folder: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The entry owning `specId`, feature first. Names are unique across the whole
|
|
34
|
+
* map (the schema refuses a package named like a feature), so the order only
|
|
35
|
+
* decides which lookup runs first, never which answer is right.
|
|
36
|
+
*/
|
|
37
|
+
export function findSpecOwner(
|
|
38
|
+
manifest: OgygieManifest,
|
|
39
|
+
specId: string,
|
|
40
|
+
): SpecOwner | undefined {
|
|
41
|
+
const feature = manifest.features.find((candidate) => candidate.name === specId);
|
|
42
|
+
if (feature !== undefined) {
|
|
43
|
+
const owningApp =
|
|
44
|
+
feature.app === undefined
|
|
45
|
+
? manifest.apps.find((app) => app.type === "api")
|
|
46
|
+
: manifest.apps.find((app) => app.name === feature.app);
|
|
47
|
+
return {
|
|
48
|
+
kind: "feature",
|
|
49
|
+
name: feature.name,
|
|
50
|
+
status: feature.status,
|
|
51
|
+
folder: owningApp === undefined ? null : `${owningApp.path}/${feature.folder}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const productPackage = (manifest.packages ?? []).find(
|
|
56
|
+
(candidate) => candidate.name === specId,
|
|
57
|
+
);
|
|
58
|
+
if (productPackage !== undefined) {
|
|
59
|
+
return {
|
|
60
|
+
kind: "package",
|
|
61
|
+
name: productPackage.name,
|
|
62
|
+
status: productPackage.status,
|
|
63
|
+
folder: productPackage.path,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What a refusal should suggest — both natures, never one that cannot exist. */
|
|
71
|
+
export const SPEC_OWNER_FIX =
|
|
72
|
+
"Set the owning entry to delivered in the manifest — a feature under `features`, or a library under `packages` — or keep the spec in validated.";
|
|
@@ -373,3 +373,86 @@ describe("manifest — apps vendored (spec product-vocabulary-gate B3, v3)", ()
|
|
|
373
373
|
expect(OgygieManifestSchema.safeParse(manifestWithApp({ vendored: false })).success).toBe(false);
|
|
374
374
|
});
|
|
375
375
|
});
|
|
376
|
+
|
|
377
|
+
/** Packages du produit (spec manifest-packages B1/B3). */
|
|
378
|
+
function manifestWithPackages(packages: unknown, features: unknown[] = []) {
|
|
379
|
+
return {
|
|
380
|
+
version: 1,
|
|
381
|
+
product: { name: "acme", description: "SaaS générique servant de fixture aux tests du manifeste." },
|
|
382
|
+
apps: [{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" }],
|
|
383
|
+
features,
|
|
384
|
+
packages,
|
|
385
|
+
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const enginePackage = {
|
|
390
|
+
name: "gadget-engine",
|
|
391
|
+
path: "packages/gadget-engine",
|
|
392
|
+
intent: "Moteur pur : aucune I/O, aucune horloge, portable navigateur.",
|
|
393
|
+
spec: "specs/gadget-engine.spec.md",
|
|
394
|
+
dependsOn: [],
|
|
395
|
+
status: "delivered",
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const featureNamed = (name: string, dependsOn: string[] = []) => ({
|
|
399
|
+
name,
|
|
400
|
+
intent: "Feature d'exemple servant de fixture aux tests du manifeste.",
|
|
401
|
+
folder: `src/features/${name}`,
|
|
402
|
+
spec: null,
|
|
403
|
+
routes: [],
|
|
404
|
+
dbTables: [],
|
|
405
|
+
dependsOn,
|
|
406
|
+
status: "delivered",
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
describe("manifest — packages du produit (spec manifest-packages)", () => {
|
|
410
|
+
it("un bloc packages valide est accepté", () => {
|
|
411
|
+
expect(OgygieManifestSchema.safeParse(manifestWithPackages([enginePackage])).success).toBe(true);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("un manifeste SANS bloc packages reste valide (l'extension est invisible)", () => {
|
|
415
|
+
const withoutPackages = manifestWithPackages(undefined);
|
|
416
|
+
delete (withoutPackages as Record<string, unknown>)["packages"];
|
|
417
|
+
expect(OgygieManifestSchema.safeParse(withoutPackages).success).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("une feature peut dépendre d'un package déclaré", () => {
|
|
421
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
422
|
+
manifestWithPackages([enginePackage], [featureNamed("orders", ["package:gadget-engine"])]),
|
|
423
|
+
);
|
|
424
|
+
expect(parsed.success).toBe(true);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("MUTATION : une dépendance vers un package INEXISTANT est refusée", () => {
|
|
428
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
429
|
+
manifestWithPackages([enginePackage], [featureNamed("orders", ["package:fantome"])]),
|
|
430
|
+
);
|
|
431
|
+
expect(parsed.success).toBe(false);
|
|
432
|
+
const issue = parsed.success ? undefined : parsed.error.issues[0];
|
|
433
|
+
expect(issue?.message).toContain("matches no declared package");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("MUTATION : un package portant le nom d'une feature est refusé", () => {
|
|
437
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
438
|
+
manifestWithPackages([{ ...enginePackage, name: "orders" }], [featureNamed("orders")]),
|
|
439
|
+
);
|
|
440
|
+
expect(parsed.success).toBe(false);
|
|
441
|
+
const messages = parsed.success ? [] : parsed.error.issues.map((issue) => issue.message);
|
|
442
|
+
expect(messages.join(" ")).toContain("already the name of a feature");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("MUTATION : un package déclaré deux fois est refusé", () => {
|
|
446
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
447
|
+
manifestWithPackages([enginePackage, { ...enginePackage, path: "packages/autre" }]),
|
|
448
|
+
);
|
|
449
|
+
expect(parsed.success).toBe(false);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("MUTATION : un package ne dépend PAS d'une feature (forme package:<nom> exigée)", () => {
|
|
453
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
454
|
+
manifestWithPackages([{ ...enginePackage, dependsOn: ["orders"] }], [featureNamed("orders")]),
|
|
455
|
+
);
|
|
456
|
+
expect(parsed.success).toBe(false);
|
|
457
|
+
});
|
|
458
|
+
});
|
package/src/manifest.schema.ts
CHANGED
|
@@ -10,6 +10,30 @@ export const FeatureNameSchema = z
|
|
|
10
10
|
.string()
|
|
11
11
|
.regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "kebab-case required");
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* A dependency on a PRODUCT package, written `package:<name>` so it can never
|
|
15
|
+
* be confused with a feature name. The prefix is the whole point: two tiroirs
|
|
16
|
+
* of the map, one syntax each, and no guessing about what a bare name means.
|
|
17
|
+
*/
|
|
18
|
+
export const PackageDependencySchema = z
|
|
19
|
+
.string()
|
|
20
|
+
.regex(/^package:[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "expected package:<kebab-case-name>");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A package of the PRODUCT: code that does not run and belongs to no app — a
|
|
24
|
+
* pure library, a portable engine, a computation without I/O. It answers the
|
|
25
|
+
* same questions as a feature, minus the ones that make no sense outside an
|
|
26
|
+
* app: no routes, no tables, no owning app.
|
|
27
|
+
*
|
|
28
|
+
* Without this, such code had nowhere to be declared, and the spec describing
|
|
29
|
+
* it could never say `implemented` — the gate rightly refuses a spec the map
|
|
30
|
+
* does not confirm, and the map had no way to confirm it (spec
|
|
31
|
+
* manifest-packages).
|
|
32
|
+
*
|
|
33
|
+
* Not to be confused with `framework.packages`, which describes what a
|
|
34
|
+
* FRAMEWORK repo publishes on npm. This block describes what a product keeps
|
|
35
|
+
* for itself.
|
|
36
|
+
*/
|
|
13
37
|
export const HttpRouteSchema = z.object({
|
|
14
38
|
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
15
39
|
path: z.string().startsWith("/api/"),
|
|
@@ -67,8 +91,10 @@ export const FeatureManifestSchema = z.object({
|
|
|
67
91
|
"Drizzle tables owned by this feature (one table = one owner). String = tenant-scoped; { name, shared: true } = shared referential",
|
|
68
92
|
),
|
|
69
93
|
dependsOn: z
|
|
70
|
-
.array(FeatureNameSchema)
|
|
71
|
-
.describe(
|
|
94
|
+
.array(z.union([FeatureNameSchema, PackageDependencySchema]))
|
|
95
|
+
.describe(
|
|
96
|
+
"Features imported through their index.ts, and product packages as package:<name> — nothing else",
|
|
97
|
+
),
|
|
72
98
|
status: z.enum(["skeleton", "in-progress", "delivered"]),
|
|
73
99
|
});
|
|
74
100
|
|
|
@@ -145,6 +171,18 @@ export const ProductVocabularySchema = z.object({
|
|
|
145
171
|
});
|
|
146
172
|
export type ProductVocabulary = z.infer<typeof ProductVocabularySchema>;
|
|
147
173
|
|
|
174
|
+
export const PackageManifestSchema = z.object({
|
|
175
|
+
name: FeatureNameSchema,
|
|
176
|
+
path: z.string().describe("Folder relative to the repo root, e.g. packages/solver"),
|
|
177
|
+
intent: z.string().min(20).max(300).describe("The package's 'why' in 1-3 sentences"),
|
|
178
|
+
spec: z.string().nullable(),
|
|
179
|
+
dependsOn: z
|
|
180
|
+
.array(PackageDependencySchema)
|
|
181
|
+
.describe("Other PRODUCT packages, as package:<name> — never a feature: a package that depended on one would live under an app, and would be a feature"),
|
|
182
|
+
status: z.enum(["skeleton", "in-progress", "delivered"]),
|
|
183
|
+
});
|
|
184
|
+
export type PackageManifest = z.infer<typeof PackageManifestSchema>;
|
|
185
|
+
|
|
148
186
|
export const OgygieManifestSchema = z.object({
|
|
149
187
|
version: z.literal(1),
|
|
150
188
|
product: z.object({
|
|
@@ -175,6 +213,10 @@ export const OgygieManifestSchema = z.object({
|
|
|
175
213
|
}),
|
|
176
214
|
),
|
|
177
215
|
features: z.array(FeatureManifestSchema),
|
|
216
|
+
packages: z
|
|
217
|
+
.array(PackageManifestSchema)
|
|
218
|
+
.optional()
|
|
219
|
+
.describe("Product code living outside every app — libraries, engines. Absent = the project has none"),
|
|
178
220
|
vocabulary: ProductVocabularySchema.optional().describe(
|
|
179
221
|
"The product's closed vocabulary — declared here, enforced by `ogygie verify-vocabulary`. Absent = gate silent",
|
|
180
222
|
),
|
|
@@ -190,6 +232,54 @@ export const OgygieManifestSchema = z.object({
|
|
|
190
232
|
// front CONSUMES contracts, the gateway RELAYS declared routes; neither
|
|
191
233
|
// OWNS any).
|
|
192
234
|
.superRefine((manifest, context) => {
|
|
235
|
+
// One name, one thing in the map (spec manifest-packages B1): a package
|
|
236
|
+
// sharing a feature's name would make `dependsOn` ambiguous — and worse,
|
|
237
|
+
// silently so, since both would resolve.
|
|
238
|
+
const featureNames = new Set(manifest.features.map((feature) => feature.name));
|
|
239
|
+
const packageNames = new Set<string>();
|
|
240
|
+
(manifest.packages ?? []).forEach((packageEntry, index) => {
|
|
241
|
+
if (featureNames.has(packageEntry.name)) {
|
|
242
|
+
context.addIssue({
|
|
243
|
+
code: "custom",
|
|
244
|
+
path: ["packages", index, "name"],
|
|
245
|
+
message: `"${packageEntry.name}" is already the name of a feature — one name designates one thing`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (packageNames.has(packageEntry.name)) {
|
|
249
|
+
context.addIssue({
|
|
250
|
+
code: "custom",
|
|
251
|
+
path: ["packages", index, "name"],
|
|
252
|
+
message: `package "${packageEntry.name}" is declared twice`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
packageNames.add(packageEntry.name);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Every `package:<name>` must designate a DECLARED package, wherever it is
|
|
259
|
+
// written: a dangling dependency describes a map nobody can follow.
|
|
260
|
+
const checkPackageDependencies = (
|
|
261
|
+
dependsOn: readonly string[],
|
|
262
|
+
path: (string | number)[],
|
|
263
|
+
): void => {
|
|
264
|
+
dependsOn.forEach((dependency, dependencyIndex) => {
|
|
265
|
+
if (!dependency.startsWith("package:")) return;
|
|
266
|
+
const target = dependency.slice("package:".length);
|
|
267
|
+
if (!packageNames.has(target)) {
|
|
268
|
+
context.addIssue({
|
|
269
|
+
code: "custom",
|
|
270
|
+
path: [...path, dependencyIndex],
|
|
271
|
+
message: `dependency "${dependency}" matches no declared package`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
manifest.features.forEach((feature, index) => {
|
|
277
|
+
checkPackageDependencies(feature.dependsOn, ["features", index, "dependsOn"]);
|
|
278
|
+
});
|
|
279
|
+
(manifest.packages ?? []).forEach((packageEntry, index) => {
|
|
280
|
+
checkPackageDependencies(packageEntry.dependsOn, ["packages", index, "dependsOn"]);
|
|
281
|
+
});
|
|
282
|
+
|
|
193
283
|
// Vocabulary coherence (B2): one concept = one term. A canonical declared
|
|
194
284
|
// twice, an alias claimed by two entries, or an alias that IS someone's
|
|
195
285
|
// canonical would make the gate's suggested fix ambiguous — refused here,
|