Skip to content

Commit 85a966f

Browse files
os-zhuangclaude
andauthored
feat(lint): check nav page/report/dashboard targets (ADR-0072) (#4574)
`defineStack`'s `validateCrossReferences` already validates these three nav target kinds. But each arm is gated on the collection being non-empty: if (nav.type === 'page' && typeof nav.pageName === 'string' && pageNames.size > 0 && !pageNames.has(nav.pageName)) { … } So a stack that declares NO `pages` at all has its page-nav check silently switched off, and `{ type: 'page', pageName: 'anything' }` sails through. That is exactly the state a stack is in when the target was never written — the most likely way to reach this, not the least. The hole is INSIDE an existing check, not a missing one. Note the asymmetry the guard creates. The `object` arm of the same block has no size gate: it errors unless the item carries `requiresObject`, an EXPLICIT opt-in to "another package provides this". Objects have to say so out loud; pages, reports and dashboards got an implicit exemption keyed on an unrelated property of the stack. `validateNavTargetRefs` joins REFERENCE_INTEGRITY_RULES (16 -> 17), so it runs on `validate` / `lint` / `compile` with no CLI rewiring. ## Warning, not error — and why `validate-object-references` can say ERROR for an unresolved OBJECT because it resolves against the curated `PLATFORM_PROVIDED_OBJECT_NAMES` registry and knows which cross-package names are real. No such registry exists for pages, reports or dashboards, so "unresolved" cannot honestly be distinguished from "provided by a package we cannot see from here". Advisory is the honest ceiling. Tightening `defineStack`'s throw was the other option and was rejected: a throw has no escape hatch for a legitimately cross-package page, and ADR-0072 D1's rule is that one dead finding costs more than a missed one. Where defineStack's check IS live it still hard-fails first; this rule speaks when that check has switched itself off, and the message says so. ## Three nav types deliberately NOT covered — each verified, not assumed action already owned by validate-action-name-refs, which walks app navigation explicitly; adding it here would double-report component VERIFIED NON-RULE. An unregistered `componentRef` does not fail silently — ComponentNavView renders a named diagnostic ("Component not registered … Ensure the plugin that provides this surface is installed and has called registerAppComponent()"), and the registry exists precisely so plugin surfaces may legitimately be absent. Flagging it would break valid plugin nav and prescribe a fix for something already reported better at runtime. url external by definition Both NON-rules are pinned by tests, so "completing" the module by adding them fails there first. ## Scope honesty All 35 authored nav page/report/dashboard targets in this repo resolve, so this closes a LATENT hole rather than a shipped bug. The rule was proven to go red and then green through the real `validateReferenceIntegrity` entry point on a known-bad stack, not only in unit tests — a green check that has never been made to fail is the recurring defect this campaign keeps finding in its own instruments. The suite's membership pin caught the new rule on first run, which is what it is for: adding a member is a deliberate two-place edit, not something that slips in unreviewed. Verification: 14 new tests; lint 776/776; full suite 132/132; API surface unchanged; i18n ratchet unchanged. Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY Co-authored-by: Claude <noreply@anthropic.com>
1 parent dab35e0 commit 85a966f

6 files changed

Lines changed: 358 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
Nav targets that are not object names (`page` / `report` / `dashboard`) are now checked at author time — closing a hole *inside* an existing check.
6+
7+
`defineStack`'s `validateCrossReferences` already validates these three. But each arm is gated on the collection being non-empty:
8+
9+
```ts
10+
if (nav.type === 'page' && typeof nav.pageName === 'string'
11+
&& pageNames.size > 0 && !pageNames.has(nav.pageName)) { … }
12+
```
13+
14+
So a stack that declares **no `pages` at all** has its page-nav check silently switched off, and `{ type: 'page', pageName: 'anything' }` sails through. That is exactly the state a stack is in when the target was never written — the most likely way to reach this bug, not the least.
15+
16+
Note the asymmetry the guard creates. The `object` arm of the same block has no size gate: it errors unless the item carries `requiresObject`, an **explicit** opt-in to "another package provides this". Objects have to say so out loud; pages, reports and dashboards got an implicit exemption that depends on an unrelated property of the stack.
17+
18+
`validateNavTargetRefs` joins `REFERENCE_INTEGRITY_RULES` (16 → 17), so it runs on `validate`, `lint` and `compile` with no CLI rewiring. It reports **warning**, not error, and that ceiling is deliberate: `validate-object-references` can say ERROR for an unresolved *object* because it resolves against the curated `PLATFORM_PROVIDED_OBJECT_NAMES` registry and knows which cross-package names are real. No such registry exists for pages, reports or dashboards, so "unresolved" cannot honestly be distinguished from "provided by a package we cannot see". Fixing the guard by tightening the parse-time throw was the other option and was rejected: a throw has no escape hatch for a legitimately cross-package page, and ADR-0072 D1's rule is that one dead finding costs more than a missed one. When `defineStack`'s check *is* live it still hard-fails first; this rule is what speaks when that check has switched itself off, and it says so in the message.
19+
20+
**Three nav types are deliberately NOT covered, each verified rather than assumed:**
21+
22+
- **`action`** — already owned by `validate-action-name-refs`, which walks app navigation explicitly. Adding it here would double-report.
23+
- **`component`** — a verified NON-rule. An unregistered `componentRef` does *not* fail silently: `ComponentNavView` renders a named diagnostic ("Component not registered … Ensure the plugin that provides this surface is installed and has called `registerAppComponent()`"), and the registry exists precisely so plugin-provided surfaces may legitimately be absent. Flagging it would break valid plugin nav and prescribe a fix for something already reported better at runtime.
24+
- **`url`** — external by definition.
25+
26+
Both NON-rules are pinned by tests, so "completing" the module by adding them fails there first.
27+
28+
**Scope honesty:** all 35 authored nav page/report/dashboard targets in this repo resolve, so this closes a latent hole rather than a shipped bug. The rule was proven to go red and then green through the real `validateReferenceIntegrity` entry point on a known-bad stack, not only in unit tests — a green check that has never been made to fail is the recurring defect this campaign keeps finding in its own instruments.

packages/lint/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,13 @@ export {
205205
} from './validate-object-references.js';
206206
export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-references.js';
207207

208+
// [ADR-0072] The non-object nav targets (page/report/dashboard). Restores the
209+
// coverage `defineStack`'s cross-reference block switches off when the stack
210+
// declares none of that collection. `action` and `component` are deliberately
211+
// NOT members — see the module doc for the verification behind each.
212+
export { validateNavTargetRefs, NAV_TARGET_UNRESOLVED } from './validate-nav-target-refs.js';
213+
export type { NavTargetRefFinding, NavTargetRefSeverity } from './validate-nav-target-refs.js';
214+
208215
export {
209216
validateSearchableFields,
210217
SEARCHABLE_FIELD_UNKNOWN,

packages/lint/src/reference-integrity-suite.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe('reference-integrity suite — membership', () => {
2121
'validatePageFieldBindings',
2222
'validateChartBindings',
2323
'validateNavAccess',
24+
'validateNavTargetRefs',
2425
'validateTranslationReferences',
2526
'validateFlowTemplatePaths',
2627
'validateAiSurfaceAffinity',

packages/lint/src/reference-integrity-suite.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { validateActionNameRefs } from './validate-action-name-refs.js';
6060
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
6161
import { validateChartBindings } from './validate-chart-bindings.js';
6262
import { validateNavAccess } from './validate-nav-access.js';
63+
import { validateNavTargetRefs } from './validate-nav-target-refs.js';
6364
import { validateTranslationReferences } from './validate-translation-references.js';
6465
import { validateFlowTemplatePaths } from './validate-flow-template-paths.js';
6566
import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js';
@@ -111,6 +112,13 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
111112
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
112113
{ name: 'validateChartBindings', run: validateChartBindings },
113114
{ name: 'validateNavAccess', run: validateNavAccess },
115+
// Nav targets that are NOT object names — page/report/dashboard. Restores the
116+
// coverage `defineStack`'s own cross-reference block switches off whenever the
117+
// stack declares none of that collection (`pageNames.size > 0 && …`), which is
118+
// exactly the state a stack is in when the target was never written.
119+
// `action` is deliberately absent (validateActionNameRefs owns it) and so is
120+
// `component` (an unregistered ref renders a named diagnostic, not silence).
121+
{ name: 'validateNavTargetRefs', run: validateNavTargetRefs },
114122
{ name: 'validateTranslationReferences', run: validateTranslationReferences },
115123
{ name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths },
116124
{ name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity },
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Tests for the non-object nav-target rule (ADR-0072).
5+
*
6+
* Two of these are the load-bearing ones:
7+
*
8+
* 1. The **empty-collection** case. That is the whole reason this rule exists:
9+
* `defineStack`'s own check is gated on `pageNames.size > 0`, so a stack with
10+
* no `pages` has its page-nav validation switched off. If someone "optimises"
11+
* this rule by skipping stacks with no pages, that test goes red.
12+
* 2. The **deliberate NON-members** (`component`, `action`). Each was verified,
13+
* not assumed — `component` renders a named "Component not registered"
14+
* diagnostic rather than failing silently, and `action` is already owned by
15+
* `validate-action-name-refs`. Flagging either would be a false prescription
16+
* or a duplicate report, and these tests are where that attempt fails first.
17+
*/
18+
19+
import { describe, expect, it } from 'vitest';
20+
21+
import { validateNavTargetRefs, NAV_TARGET_UNRESOLVED } from './validate-nav-target-refs.js';
22+
23+
const app = (items: unknown[]) => ({ apps: [{ name: 'ops', navigation: items }] });
24+
25+
describe('validateNavTargetRefs — the gap defineStack leaves', () => {
26+
it('flags a page target when the stack declares NO pages (the size>0 hole)', () => {
27+
const [f] = validateNavTargetRefs(app([{ id: 'n1', type: 'page', pageName: 'missing_page' }]));
28+
expect(f.rule).toBe(NAV_TARGET_UNRESOLVED);
29+
expect(f.severity).toBe('warning');
30+
expect(f.path).toBe('apps[0].navigation[0].pageName');
31+
expect(f.where).toBe('app "ops" · nav "n1"');
32+
// The message must say WHY nothing else caught it, or the author has no
33+
// way to know this rule is the only thing speaking.
34+
expect(f.message).toContain('NO pages at all');
35+
expect(f.message).toContain('size > 0');
36+
});
37+
38+
it('flags a page target when pages exist but the name is wrong', () => {
39+
const findings = validateNavTargetRefs({
40+
...app([{ id: 'n1', type: 'page', pageName: 'typo' }]),
41+
pages: [{ name: 'real_page' }],
42+
});
43+
expect(findings).toHaveLength(1);
44+
expect(findings[0].message).not.toContain('NO pages at all');
45+
});
46+
47+
it('is silent when the target resolves', () => {
48+
expect(validateNavTargetRefs({
49+
...app([{ id: 'n1', type: 'page', pageName: 'real_page' }]),
50+
pages: [{ name: 'real_page' }],
51+
})).toEqual([]);
52+
});
53+
54+
it.each([
55+
['report', 'reportName', 'reports'],
56+
['dashboard', 'dashboardName', 'dashboards'],
57+
])('covers %s targets too', (type, prop, collection) => {
58+
expect(validateNavTargetRefs(app([{ id: 'x', type, [prop]: 'nope' }]))).toHaveLength(1);
59+
expect(validateNavTargetRefs({
60+
...app([{ id: 'x', type, [prop]: 'ok' }]),
61+
[collection]: [{ name: 'ok' }],
62+
})).toEqual([]);
63+
});
64+
65+
it('walks nested children — an `object` item carries them too, not just a group', () => {
66+
const findings = validateNavTargetRefs(app([
67+
{ id: 'grp', type: 'object', objectName: 'task', children: [
68+
{ id: 'deep', type: 'page', pageName: 'missing' },
69+
] },
70+
]));
71+
expect(findings).toHaveLength(1);
72+
expect(findings[0].path).toBe('apps[0].navigation[0].children[0].pageName');
73+
});
74+
75+
it('walks the `areas[]` container, not just `navigation`', () => {
76+
const findings = validateNavTargetRefs({
77+
apps: [{ name: 'ops', areas: [{ name: 'a', items: [{ id: 'n', type: 'page', pageName: 'missing' }] }] }],
78+
});
79+
expect(findings).toHaveLength(1);
80+
expect(findings[0].path).toBe('apps[0].areas[0].items[0].pageName');
81+
});
82+
83+
it('skips interpolated targets — they resolve at render time (ADR-0072 D1)', () => {
84+
expect(validateNavTargetRefs(app([
85+
{ id: 'n', type: 'page', pageName: '${ctx.page}' },
86+
{ id: 'm', type: 'page', pageName: '{dynamic}' },
87+
]))).toEqual([]);
88+
});
89+
});
90+
91+
describe('the deliberate NON-members — each verified, not assumed', () => {
92+
it('does NOT flag `component` — an unregistered ref is reported loudly at runtime', () => {
93+
// ComponentNavView renders "Component not registered … Ensure the plugin
94+
// that provides this surface is installed and has called
95+
// registerAppComponent()". The registry exists so plugin surfaces MAY be
96+
// absent; flagging this would break valid plugin nav and duplicate a
97+
// better runtime message.
98+
expect(validateNavTargetRefs(app([
99+
{ id: 'n', type: 'component', componentRef: 'nobody:nothing' },
100+
]))).toEqual([]);
101+
});
102+
103+
it('does NOT flag `action` — validate-action-name-refs already walks app nav', () => {
104+
expect(validateNavTargetRefs(app([
105+
{ id: 'n', type: 'action', actionDef: { actionName: 'ghost_action' } },
106+
]))).toEqual([]);
107+
});
108+
109+
it('does NOT flag `url` — external by definition', () => {
110+
expect(validateNavTargetRefs(app([
111+
{ id: 'n', type: 'url', url: 'https://example.com' },
112+
]))).toEqual([]);
113+
});
114+
});
115+
116+
describe('robustness', () => {
117+
it('never throws on junk or partial stacks', () => {
118+
for (const junk of [
119+
undefined, null, 42, 'x', [], {},
120+
{ apps: 'nope' }, { apps: [null, 7] },
121+
{ apps: [{ name: 'a', navigation: 'nope' }] },
122+
{ apps: [{ navigation: [null, 3, { type: 'page' }] }] },
123+
{ apps: [{ name: 'a', areas: 'nope' }] },
124+
app([{ type: 'page' }]), // no pageName at all
125+
]) {
126+
expect(() => validateNavTargetRefs(junk)).not.toThrow();
127+
}
128+
});
129+
130+
it('emits nothing for a stack with no apps', () => {
131+
expect(validateNavTargetRefs({ pages: [{ name: 'p' }] })).toEqual([]);
132+
});
133+
134+
it('every finding carries a usable hint', () => {
135+
for (const f of validateNavTargetRefs(app([{ id: 'n', type: 'page', pageName: 'x' }]))) {
136+
expect(f.hint.length).toBeGreaterThan(20);
137+
}
138+
});
139+
});
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0072 — reference resolvability] App-navigation targets that are not
5+
* object names: `page`, `report`, `dashboard`.
6+
*
7+
* ## The hole this closes is inside an EXISTING check, not a missing one
8+
*
9+
* `defineStack`'s `validateCrossReferences` already validates these three
10+
* (`stack.zod.ts`, the "Validate app navigation → object/dashboard/page/report
11+
* references" block). But each of the three is guarded on the collection being
12+
* non-empty:
13+
*
14+
* ```ts
15+
* if (nav.type === 'page' && typeof nav.pageName === 'string'
16+
* && pageNames.size > 0 && !pageNames.has(nav.pageName)) { … }
17+
* ```
18+
*
19+
* So a stack that declares **no `pages` at all** has its page-nav check
20+
* silently switched off, and `{ type: 'page', pageName: 'anything' }` sails
21+
* through. That is precisely the state a stack is in when the target was never
22+
* written — the most likely way to get here, not the least.
23+
*
24+
* Note the asymmetry the guard creates. The `object` arm of the same block has
25+
* no size gate: it errors unless the item carries `requiresObject`, an
26+
* EXPLICIT opt-in to "another package provides this". Objects therefore say so
27+
* out loud; pages, reports and dashboards get an implicit exemption that
28+
* depends on an unrelated property of the stack.
29+
*
30+
* This rule restores the coverage with the ADR-0072 severity posture rather
31+
* than by tightening the parse-time throw — a throw has no escape hatch for a
32+
* legitimately cross-package page, and ADR-0072 D1's rule is that one dead
33+
* finding costs more than a missed one.
34+
*
35+
* ## Severity: warning, and why it is not error
36+
*
37+
* `validate-object-references` can say ERROR for an unresolved *object*
38+
* because it resolves against a curated `PLATFORM_PROVIDED_OBJECT_NAMES`
39+
* registry — it knows which cross-package names are real. No such registry
40+
* exists for pages, reports or dashboards, so "unresolved" genuinely cannot be
41+
* distinguished from "provided by a package we cannot see from here".
42+
* Advisory is the honest ceiling. When `defineStack`'s own check is live (the
43+
* collection is non-empty) it still hard-fails first; this rule is what speaks
44+
* when that check has switched itself off.
45+
*
46+
* ## Deliberately NOT covered — each verified, not assumed
47+
*
48+
* - **`action`** (`actionDef.actionName`) — already owned by
49+
* `validate-action-name-refs`, which walks app navigation explicitly. Adding
50+
* it here would double-report the same finding.
51+
* - **`component`** (`componentRef`) — verified a NON-rule. An unregistered ref
52+
* does NOT fail silently: `ComponentNavView` renders a named diagnostic
53+
* ("Component not registered … Ensure the plugin that provides this surface
54+
* is installed and has called `registerAppComponent()`"), and the registry
55+
* exists precisely so plugin-provided surfaces may legitimately be absent.
56+
* Flagging it would break valid plugin nav and prescribe a fix for something
57+
* already reported better at runtime.
58+
* - **`url`** — external by definition; nothing to resolve against.
59+
*/
60+
61+
import type { ReferenceIntegrityFinding } from './reference-integrity-suite.js';
62+
63+
export type NavTargetRefSeverity = 'error' | 'warning';
64+
export type NavTargetRefFinding = ReferenceIntegrityFinding;
65+
66+
/** Emitted when a nav item targets a page/report/dashboard the stack cannot resolve. */
67+
export const NAV_TARGET_UNRESOLVED = 'nav-target-unresolved';
68+
69+
type AnyRec = Record<string, unknown>;
70+
71+
const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v);
72+
73+
function asArray(v: unknown): AnyRec[] {
74+
if (Array.isArray(v)) return v.filter(isRec);
75+
if (isRec(v)) return Object.entries(v).map(([name, def]) => (isRec(def) ? { name, ...def } : { name }));
76+
return [];
77+
}
78+
79+
function strName(v: unknown): string | undefined {
80+
return typeof v === 'string' && v.length > 0 ? v : undefined;
81+
}
82+
83+
/**
84+
* An interpolated target resolves at render time — the same conservative
85+
* exemption `validate-object-references` and `validate-dashboard-action-refs`
86+
* use to keep false positives near zero (ADR-0072 D1).
87+
*/
88+
const isInterpolated = (s: string): boolean => s.includes('${') || s.includes('{');
89+
90+
/** nav `type` → [target property, stack collection, human noun]. */
91+
const NAV_TARGETS: ReadonlyArray<readonly [string, string, string, string]> = [
92+
['page', 'pageName', 'pages', 'page'],
93+
['report', 'reportName', 'reports', 'report'],
94+
['dashboard', 'dashboardName', 'dashboards', 'dashboard'],
95+
];
96+
97+
function namesOf(collection: unknown): Set<string> {
98+
const out = new Set<string>();
99+
for (const entry of asArray(collection)) {
100+
const n = strName(entry.name);
101+
if (n) out.add(n);
102+
}
103+
return out;
104+
}
105+
106+
export function validateNavTargetRefs(stack: unknown): NavTargetRefFinding[] {
107+
const findings: NavTargetRefFinding[] = [];
108+
if (!isRec(stack)) return findings;
109+
110+
const apps = asArray(stack.apps);
111+
if (apps.length === 0) return findings;
112+
113+
const declared = new Map<string, Set<string>>();
114+
for (const [, , collection] of NAV_TARGETS) {
115+
declared.set(collection, namesOf((stack as AnyRec)[collection]));
116+
}
117+
118+
for (const [ai, app] of apps.entries()) {
119+
const appName = strName(app.name) ?? `#${ai}`;
120+
121+
const walk = (items: unknown, basePath: string): void => {
122+
if (!Array.isArray(items)) return;
123+
for (const [ni, raw] of items.entries()) {
124+
if (!isRec(raw)) continue;
125+
const nav = raw;
126+
const navPath = `${basePath}[${ni}]`;
127+
128+
for (const [type, prop, collection, noun] of NAV_TARGETS) {
129+
if (nav.type !== type) continue;
130+
const target = strName(nav[prop]);
131+
if (!target || isInterpolated(target)) continue;
132+
const known = declared.get(collection)!;
133+
if (known.has(target)) continue;
134+
135+
const emptyCollection = known.size === 0;
136+
findings.push({
137+
severity: 'warning',
138+
rule: NAV_TARGET_UNRESOLVED,
139+
where: `app "${appName}" · nav "${strName(nav.id) ?? strName(nav.label) ?? `#${ni}`}"`,
140+
path: `${navPath}.${prop}`,
141+
message:
142+
`Navigation targets ${noun} '${target}', which this stack does not declare in `
143+
+ `\`${collection}\`. `
144+
+ (emptyCollection
145+
? `The stack declares NO ${collection} at all, so \`defineStack\`'s own `
146+
+ `cross-reference check skipped this entry entirely (it is gated on `
147+
+ `\`${collection === 'pages' ? 'pageNames' : collection === 'reports' ? 'reportNames' : 'dashboardNames'}.size > 0\`) — `
148+
+ `nothing else will report it. `
149+
: '')
150+
+ `The entry renders in the sidebar and resolves to nothing when clicked. If another `
151+
+ `package provides this ${noun}, this is expected and advisory only.`,
152+
hint:
153+
`Declare the ${noun} in \`${collection}\`, correct the name, or remove the nav entry `
154+
+ `if the ${noun} is gone.`,
155+
});
156+
}
157+
158+
// Recurse: an `object` nav item carries `children` too, not just a
159+
// `group` — the same reason `stack.zod.ts` does not gate its recursion
160+
// on the item type.
161+
if (Array.isArray(nav.children)) walk(nav.children, `${navPath}.children`);
162+
}
163+
};
164+
165+
walk(app.navigation, `apps[${ai}].navigation`);
166+
// `areas[]` is the other nav container; it was once skipped wholesale in
167+
// `stack.zod.ts`, so an areas-based app got no nav validation at all.
168+
for (const [ari, area] of asArray(app.areas).entries()) {
169+
walk(area.items, `apps[${ai}].areas[${ari}].items`);
170+
walk(area.navigation, `apps[${ai}].areas[${ari}].navigation`);
171+
}
172+
}
173+
174+
return findings;
175+
}

0 commit comments

Comments
 (0)