Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/eighty-pumas-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': patch
---

Walk object-nested `list` / `listViews.*` through the view completeness rules.

`validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
ADR-0017 "Object has-many View" spelling that `objects[].list` and
`objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
register the same expanded view items and reach the same renderer, so `os validate`
and `os build` were silent on exactly the half of the stack the sibling rules
(`lint-view-refs`, `validate-list-view-field-refs`) already walk.

Both authorable object spellings (array-form and name-keyed map) are covered, and a
list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
way it does on the top-level door — so `view/layout-without-binding` and
`view/tree-without-parent-field` now reach the nested door by construction rather
than by a second wiring step. Findings report as
`object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
181 changes: 181 additions & 0 deletions packages/lint/src/validate-functional-completeness.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,100 @@ describe('validateFunctionalCompleteness — the walk', () => {
})).toEqual([]);
});

// ── The OBJECT-NESTED door (ADR-0017 "Object has-many View") ────────────
// A container authored on the object itself — `objects[].list` /
// `objects[].listViews.*` — reaches the same renderer as the top-level
// `views[]` copy, so it must reach the same rules. The walk used to stop at
// `stack.views`, which is precisely the half-blind instrument this suite's
// docblock names.

it('walks an object-nested `listViews` view — ARRAY-form objects', () => {
const findings = validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [{ name: 'title', type: 'text' }], listViews: { plan: { type: 'gantt' } } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].severity).toBe('warning');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the same view when objects are name-keyed MAPS', () => {
// The fixture pair the card asks for: both authorable object spellings,
// one rule, one location grammar (the object segment is positional in
// both — the field walk above and `validate-list-view-field-refs` spell
// it the same way).
const findings = validateFunctionalCompleteness({
objects: { task: { fields: { title: { type: 'text' } }, listViews: { plan: { type: 'gantt' } } } },
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the object-nested default `list` slot too', () => {
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], list: { type: 'timeline' } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/layout-without-binding', 'object "task" › list', 'objects[0].list.timeline'],
]);
});

it('carries `view/tree-without-parent-field` through the nested door as well', () => {
// Not a second wiring step: the tree rule needs the BOUND OBJECT, and the
// nested door resolves it to the container's own object, so every rule the
// predicate has arrives here by construction.
expect(validateFunctionalCompleteness({
objects: [{ name: 'unit', fields: [{ name: 'name', type: 'text' }], listViews: { org: { type: 'tree', tree: {} } } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/tree-without-parent-field', 'object "unit" › listViews.org', 'objects[0].listViews.org.tree.parentField'],
]);

// A self-lookup on the object → the renderer auto-detects → silent.
expect(validateFunctionalCompleteness({
objects: { unit: { fields: { parent: { type: 'lookup', reference: 'unit' } }, listViews: { org: { type: 'tree', tree: {} } } } },
})).toEqual([]);

// ADR-0047: the nested view's own `data.object` retargets the lookup, so
// the rule reads `cat`'s fields, not `unit`'s.
expect(validateFunctionalCompleteness({
objects: [
{ name: 'unit', fields: [], listViews: { org: { type: 'tree', tree: {}, data: { provider: 'object', object: 'cat' } } } },
{ name: 'cat', fields: [{ name: 'parent', type: 'tree' }] },
],
})).toEqual([]);
});

it('does not over-report: a nested view WITH its binding is silent', () => {
// The negative half of the pair. A widened walk that fired on complete
// metadata would be worse than the blind one it replaces.
expect(validateFunctionalCompleteness({
objects: [{
name: 'task',
fields: [{ name: 'title', type: 'text' }, { name: 'starts', type: 'datetime' }, { name: 'parent', type: 'lookup', reference: 'task' }],
list: { type: 'grid' },
listViews: {
plan: { type: 'gantt', gantt: { startDateField: 'starts', titleField: 'title' } },
org: { type: 'tree', tree: {} },
flat: { type: 'grid' },
},
}],
})).toEqual([]);
});

it('reports the nested and the top-level copy independently', () => {
// The two doors are separate authorable surfaces, not two readings of one
// definition — an author who wrote both gets a finding for each.
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], listViews: { plan: { type: 'gantt' } } }],
views: [{ object: 'task', listViews: { plan: { type: 'gantt' } } }],
}).map((f) => f.path).sort()).toEqual([
'objects[0].listViews.plan.gantt',
'views[0].listViews.plan.gantt',
]);
});

it('walks webhooks in both spellings', () => {
expect(validateFunctionalCompleteness({
webhooks: [{ name: 'notify', url: 'https://x' }],
Expand DownExpand Up@@ -167,6 +261,8 @@ describe('validateFunctionalCompleteness — the walk', () => {
undefined, null, 42, 'x', [], {},
{ objects: 'nope' }, { objects: [null, 7] },
{ objects: [{ name: 'o', fields: 'nope' }] },
{ objects: [{ name: 'o', list: null, listViews: 'nope' }] },
{ objects: { o: { list: 7, listViews: [null, 'x'] } } },
{ views: [{ list: null }] },
{ views: 'nope' },
{ webhooks: 'nope' }, { webhooks: [null, 7] },
Expand DownExpand Up@@ -244,3 +340,88 @@ describe('#14106 acceptance — timeline / map / tree bindings reach `validate`
});
}
});

/**
* The #14320 acceptance criteria, pinned end-to-end through the rule table on
* the card's own repro path (`runAuthoringRules('validate' | 'build', …)`) —
* because the walk being wired is only worth as much as the surface it reaches.
*
* The stack below authors nothing at `stack.views`: every list view lives on
* its object, the ADR-0017 spelling `os validate` used to walk right past.
*/
describe('#14320 acceptance — object-nested `list` / `listViews` reach `validate` AND `build`', () => {
const fields = {
subject: { type: 'text' },
last_update_at: { type: 'datetime' },
site: { type: 'text' },
};
const BINDING_RULES = ['view/layout-without-binding', 'view/tree-without-parent-field'];

/** Array-form objects — the `defineObject` spelling. */
const repro = {
objects: [{
name: 'duly_task',
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
}],
};

/** Map-form objects — the same stack, the other authorable spelling. */
const reproMap = {
objects: {
duly_task: {
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
},
},
};

const EXPECTED = [
'view/layout-without-binding @ objects[0].list.timeline',
'view/layout-without-binding @ objects[0].listViews.plan.gantt',
'view/layout-without-binding @ objects[0].listViews.sites.map',
'view/tree-without-parent-field @ objects[0].listViews.flat.tree.parentField',
];

const clean = {
objects: [{
name: 'duly_task',
fields: { ...fields, parent: { type: 'lookup', reference: 'duly_task' } },
list: { type: 'timeline', columns: ['subject'], timeline: { startDateField: 'last_update_at', titleField: 'subject' } },
listViews: {
plan: { type: 'gantt', columns: ['subject'], gantt: { startDateField: 'last_update_at', titleField: 'subject' } },
sites: { type: 'map', columns: ['subject'], map: { locationField: 'site' } },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
declared: { type: 'tree', tree: { parentField: 'parent' }, columns: ['subject'] },
},
}],
};

for (const command of ['validate', 'build'] as const) {
for (const [spelling, stack] of [['array-form', repro], ['map-form', reproMap]] as const) {
it(`the ${spelling} repro is diagnosed by \`${command}\``, () => {
const { advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack as never }));
const hits = advisories
.filter((f) => BINDING_RULES.includes(f.rule))
.map((f) => `${f.rule} @ ${f.path}`)
.sort();
expect(hits).toEqual(EXPECTED);
});
}

it(`the bound object-nested stack passes \`${command}\``, () => {
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: clean as never }));
expect([...errors, ...advisories].filter((f) => BINDING_RULES.includes(f.rule))).toEqual([]);
});
}
});
51 changes: 46 additions & 5 deletions packages/lint/src/validate-functional-completeness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,11 +116,13 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
// is `validate-object-references`' finding.
const objectsByName = new Map(entriesOf(stack.objects).map((o) => [o.name, o.def]));
const strName = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined => {
const own = isRec(view.data) ? strName(view.data.object) : undefined;
const name = own ?? strName(container.objectName) ?? strName(container.object);
return name ? objectsByName.get(name) : undefined;
};
const byName = (name: string | undefined): AnyRec | undefined =>
name ? objectsByName.get(name) : undefined;
/** A list view's own `data.object` retarget (ADR-0047), when it declares one. */
const ownObjectOf = (view: AnyRec): string | undefined =>
(isRec(view.data) ? strName(view.data.object) : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined =>
byName(ownObjectOf(view) ?? strName(container.objectName) ?? strName(container.object));
for (const [vi, container] of entriesOf(stack.views).entries()) {
const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`;
if (isRec(container.def.list)) {
Expand All@@ -141,6 +143,45 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
}
}

// ── List views: the OBJECT-NESTED container → objects[].list /
// objects[].listViews.* (ADR-0017 "Object has-many View") ─────────────
//
// The same two slots, reached through the other authorable door. An object
// carries its own view container — `lint-view-refs`'s `containerFromObject`
// pulls `list` / `listViews` straight off the object definition, and the
// ADR-0017 loader's `isAggregatedViewContainer` / `expandViewContainer`
// register the expansion — so a `timeline` / `gantt` / `tree` authored here
// reaches exactly the renderer the top-level container's copy reaches. The
// walk above stopped at `stack.views`, which made this gate report coverage
// it did not have: green, and blind to the other half of the stack.
//
// Bound object: the object the container belongs to, unless the view's own
// `data.object` retargets it (ADR-0047) — the resolution
// `validate-list-view-field-refs` uses on this identical rung
// (`listViewObject(lv) ?? objName`). Location grammar is this file's own,
// shared with the field walk directly above: a positional `objects[<i>]`
// (both spellings normalize to one ordered list) and the slot's authored
// key.
for (const [oi, obj] of entriesOf(stack.objects).entries()) {
const where = `object "${obj.name}"`;
if (isRec(obj.def.list)) {
push(
out,
checkViewCompleteness(obj.def.list, byName(ownObjectOf(obj.def.list) ?? obj.name)),
`${where} › list`,
`objects[${oi}].list`,
);
}
for (const lv of entriesOf(obj.def.listViews)) {
push(
out,
checkViewCompleteness(lv.def, byName(ownObjectOf(lv.def) ?? obj.name)),
`${where} › listViews.${lv.name}`,
`objects[${oi}].listViews${lv.key}`,
);
}
}

// ── Webhooks: stack.webhooks[] ─────────────────────────────────────────
// [ADR-0078 Phase 3] The one Tier-B candidate that survived its verification
// pass. A webhook materializes into `sys_webhook` and looks armed in Setup
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/eighty-pumas-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': patch
---

Walk object-nested `list` / `listViews.*` through the view completeness rules.

`validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
ADR-0017 "Object has-many View" spelling that `objects[].list` and
`objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
register the same expanded view items and reach the same renderer, so `os validate`
and `os build` were silent on exactly the half of the stack the sibling rules
(`lint-view-refs`, `validate-list-view-field-refs`) already walk.

Both authorable object spellings (array-form and name-keyed map) are covered, and a
list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
way it does on the top-level door — so `view/layout-without-binding` and
`view/tree-without-parent-field` now reach the nested door by construction rather
than by a second wiring step. Findings report as
`object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
181 changes: 181 additions & 0 deletions packages/lint/src/validate-functional-completeness.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,100 @@ describe('validateFunctionalCompleteness — the walk', () => {
})).toEqual([]);
});

// ── The OBJECT-NESTED door (ADR-0017 "Object has-many View") ────────────
// A container authored on the object itself — `objects[].list` /
// `objects[].listViews.*` — reaches the same renderer as the top-level
// `views[]` copy, so it must reach the same rules. The walk used to stop at
// `stack.views`, which is precisely the half-blind instrument this suite's
// docblock names.

it('walks an object-nested `listViews` view — ARRAY-form objects', () => {
const findings = validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [{ name: 'title', type: 'text' }], listViews: { plan: { type: 'gantt' } } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].severity).toBe('warning');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the same view when objects are name-keyed MAPS', () => {
// The fixture pair the card asks for: both authorable object spellings,
// one rule, one location grammar (the object segment is positional in
// both — the field walk above and `validate-list-view-field-refs` spell
// it the same way).
const findings = validateFunctionalCompleteness({
objects: { task: { fields: { title: { type: 'text' } }, listViews: { plan: { type: 'gantt' } } } },
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the object-nested default `list` slot too', () => {
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], list: { type: 'timeline' } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/layout-without-binding', 'object "task" › list', 'objects[0].list.timeline'],
]);
});

it('carries `view/tree-without-parent-field` through the nested door as well', () => {
// Not a second wiring step: the tree rule needs the BOUND OBJECT, and the
// nested door resolves it to the container's own object, so every rule the
// predicate has arrives here by construction.
expect(validateFunctionalCompleteness({
objects: [{ name: 'unit', fields: [{ name: 'name', type: 'text' }], listViews: { org: { type: 'tree', tree: {} } } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/tree-without-parent-field', 'object "unit" › listViews.org', 'objects[0].listViews.org.tree.parentField'],
]);

// A self-lookup on the object → the renderer auto-detects → silent.
expect(validateFunctionalCompleteness({
objects: { unit: { fields: { parent: { type: 'lookup', reference: 'unit' } }, listViews: { org: { type: 'tree', tree: {} } } } },
})).toEqual([]);

// ADR-0047: the nested view's own `data.object` retargets the lookup, so
// the rule reads `cat`'s fields, not `unit`'s.
expect(validateFunctionalCompleteness({
objects: [
{ name: 'unit', fields: [], listViews: { org: { type: 'tree', tree: {}, data: { provider: 'object', object: 'cat' } } } },
{ name: 'cat', fields: [{ name: 'parent', type: 'tree' }] },
],
})).toEqual([]);
});

it('does not over-report: a nested view WITH its binding is silent', () => {
// The negative half of the pair. A widened walk that fired on complete
// metadata would be worse than the blind one it replaces.
expect(validateFunctionalCompleteness({
objects: [{
name: 'task',
fields: [{ name: 'title', type: 'text' }, { name: 'starts', type: 'datetime' }, { name: 'parent', type: 'lookup', reference: 'task' }],
list: { type: 'grid' },
listViews: {
plan: { type: 'gantt', gantt: { startDateField: 'starts', titleField: 'title' } },
org: { type: 'tree', tree: {} },
flat: { type: 'grid' },
},
}],
})).toEqual([]);
});

it('reports the nested and the top-level copy independently', () => {
// The two doors are separate authorable surfaces, not two readings of one
// definition — an author who wrote both gets a finding for each.
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], listViews: { plan: { type: 'gantt' } } }],
views: [{ object: 'task', listViews: { plan: { type: 'gantt' } } }],
}).map((f) => f.path).sort()).toEqual([
'objects[0].listViews.plan.gantt',
'views[0].listViews.plan.gantt',
]);
});

it('walks webhooks in both spellings', () => {
expect(validateFunctionalCompleteness({
webhooks: [{ name: 'notify', url: 'https://x' }],
Expand DownExpand Up@@ -167,6 +261,8 @@ describe('validateFunctionalCompleteness — the walk', () => {
undefined, null, 42, 'x', [], {},
{ objects: 'nope' }, { objects: [null, 7] },
{ objects: [{ name: 'o', fields: 'nope' }] },
{ objects: [{ name: 'o', list: null, listViews: 'nope' }] },
{ objects: { o: { list: 7, listViews: [null, 'x'] } } },
{ views: [{ list: null }] },
{ views: 'nope' },
{ webhooks: 'nope' }, { webhooks: [null, 7] },
Expand DownExpand Up@@ -244,3 +340,88 @@ describe('#14106 acceptance — timeline / map / tree bindings reach `validate`
});
}
});

/**
* The #14320 acceptance criteria, pinned end-to-end through the rule table on
* the card's own repro path (`runAuthoringRules('validate' | 'build', …)`) —
* because the walk being wired is only worth as much as the surface it reaches.
*
* The stack below authors nothing at `stack.views`: every list view lives on
* its object, the ADR-0017 spelling `os validate` used to walk right past.
*/
describe('#14320 acceptance — object-nested `list` / `listViews` reach `validate` AND `build`', () => {
const fields = {
subject: { type: 'text' },
last_update_at: { type: 'datetime' },
site: { type: 'text' },
};
const BINDING_RULES = ['view/layout-without-binding', 'view/tree-without-parent-field'];

/** Array-form objects — the `defineObject` spelling. */
const repro = {
objects: [{
name: 'duly_task',
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
}],
};

/** Map-form objects — the same stack, the other authorable spelling. */
const reproMap = {
objects: {
duly_task: {
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
},
},
};

const EXPECTED = [
'view/layout-without-binding @ objects[0].list.timeline',
'view/layout-without-binding @ objects[0].listViews.plan.gantt',
'view/layout-without-binding @ objects[0].listViews.sites.map',
'view/tree-without-parent-field @ objects[0].listViews.flat.tree.parentField',
];

const clean = {
objects: [{
name: 'duly_task',
fields: { ...fields, parent: { type: 'lookup', reference: 'duly_task' } },
list: { type: 'timeline', columns: ['subject'], timeline: { startDateField: 'last_update_at', titleField: 'subject' } },
listViews: {
plan: { type: 'gantt', columns: ['subject'], gantt: { startDateField: 'last_update_at', titleField: 'subject' } },
sites: { type: 'map', columns: ['subject'], map: { locationField: 'site' } },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
declared: { type: 'tree', tree: { parentField: 'parent' }, columns: ['subject'] },
},
}],
};

for (const command of ['validate', 'build'] as const) {
for (const [spelling, stack] of [['array-form', repro], ['map-form', reproMap]] as const) {
it(`the ${spelling} repro is diagnosed by \`${command}\``, () => {
const { advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack as never }));
const hits = advisories
.filter((f) => BINDING_RULES.includes(f.rule))
.map((f) => `${f.rule} @ ${f.path}`)
.sort();
expect(hits).toEqual(EXPECTED);
});
}

it(`the bound object-nested stack passes \`${command}\``, () => {
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: clean as never }));
expect([...errors, ...advisories].filter((f) => BINDING_RULES.includes(f.rule))).toEqual([]);
});
}
});
51 changes: 46 additions & 5 deletions packages/lint/src/validate-functional-completeness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,11 +116,13 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
// is `validate-object-references`' finding.
const objectsByName = new Map(entriesOf(stack.objects).map((o) => [o.name, o.def]));
const strName = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined => {
const own = isRec(view.data) ? strName(view.data.object) : undefined;
const name = own ?? strName(container.objectName) ?? strName(container.object);
return name ? objectsByName.get(name) : undefined;
};
const byName = (name: string | undefined): AnyRec | undefined =>
name ? objectsByName.get(name) : undefined;
/** A list view's own `data.object` retarget (ADR-0047), when it declares one. */
const ownObjectOf = (view: AnyRec): string | undefined =>
(isRec(view.data) ? strName(view.data.object) : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined =>
byName(ownObjectOf(view) ?? strName(container.objectName) ?? strName(container.object));
for (const [vi, container] of entriesOf(stack.views).entries()) {
const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`;
if (isRec(container.def.list)) {
Expand All@@ -141,6 +143,45 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
}
}

// ── List views: the OBJECT-NESTED container → objects[].list /
// objects[].listViews.* (ADR-0017 "Object has-many View") ─────────────
//
// The same two slots, reached through the other authorable door. An object
// carries its own view container — `lint-view-refs`'s `containerFromObject`
// pulls `list` / `listViews` straight off the object definition, and the
// ADR-0017 loader's `isAggregatedViewContainer` / `expandViewContainer`
// register the expansion — so a `timeline` / `gantt` / `tree` authored here
// reaches exactly the renderer the top-level container's copy reaches. The
// walk above stopped at `stack.views`, which made this gate report coverage
// it did not have: green, and blind to the other half of the stack.
//
// Bound object: the object the container belongs to, unless the view's own
// `data.object` retargets it (ADR-0047) — the resolution
// `validate-list-view-field-refs` uses on this identical rung
// (`listViewObject(lv) ?? objName`). Location grammar is this file's own,
// shared with the field walk directly above: a positional `objects[<i>]`
// (both spellings normalize to one ordered list) and the slot's authored
// key.
for (const [oi, obj] of entriesOf(stack.objects).entries()) {
const where = `object "${obj.name}"`;
if (isRec(obj.def.list)) {
push(
out,
checkViewCompleteness(obj.def.list, byName(ownObjectOf(obj.def.list) ?? obj.name)),
`${where} › list`,
`objects[${oi}].list`,
);
}
for (const lv of entriesOf(obj.def.listViews)) {
push(
out,
checkViewCompleteness(lv.def, byName(ownObjectOf(lv.def) ?? obj.name)),
`${where} › listViews.${lv.name}`,
`objects[${oi}].listViews${lv.key}`,
);
}
}

// ── Webhooks: stack.webhooks[] ─────────────────────────────────────────
// [ADR-0078 Phase 3] The one Tier-B candidate that survived its verification
// pass. A webhook materializes into `sys_webhook` and looks armed in Setup
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/eighty-pumas-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': patch
---

Walk object-nested `list` / `listViews.*` through the view completeness rules.

`validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
ADR-0017 "Object has-many View" spelling that `objects[].list` and
`objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
register the same expanded view items and reach the same renderer, so `os validate`
and `os build` were silent on exactly the half of the stack the sibling rules
(`lint-view-refs`, `validate-list-view-field-refs`) already walk.

Both authorable object spellings (array-form and name-keyed map) are covered, and a
list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
way it does on the top-level door — so `view/layout-without-binding` and
`view/tree-without-parent-field` now reach the nested door by construction rather
than by a second wiring step. Findings report as
`object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
181 changes: 181 additions & 0 deletions packages/lint/src/validate-functional-completeness.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,100 @@ describe('validateFunctionalCompleteness — the walk', () => {
})).toEqual([]);
});

// ── The OBJECT-NESTED door (ADR-0017 "Object has-many View") ────────────
// A container authored on the object itself — `objects[].list` /
// `objects[].listViews.*` — reaches the same renderer as the top-level
// `views[]` copy, so it must reach the same rules. The walk used to stop at
// `stack.views`, which is precisely the half-blind instrument this suite's
// docblock names.

it('walks an object-nested `listViews` view — ARRAY-form objects', () => {
const findings = validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [{ name: 'title', type: 'text' }], listViews: { plan: { type: 'gantt' } } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].severity).toBe('warning');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the same view when objects are name-keyed MAPS', () => {
// The fixture pair the card asks for: both authorable object spellings,
// one rule, one location grammar (the object segment is positional in
// both — the field walk above and `validate-list-view-field-refs` spell
// it the same way).
const findings = validateFunctionalCompleteness({
objects: { task: { fields: { title: { type: 'text' } }, listViews: { plan: { type: 'gantt' } } } },
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the object-nested default `list` slot too', () => {
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], list: { type: 'timeline' } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/layout-without-binding', 'object "task" › list', 'objects[0].list.timeline'],
]);
});

it('carries `view/tree-without-parent-field` through the nested door as well', () => {
// Not a second wiring step: the tree rule needs the BOUND OBJECT, and the
// nested door resolves it to the container's own object, so every rule the
// predicate has arrives here by construction.
expect(validateFunctionalCompleteness({
objects: [{ name: 'unit', fields: [{ name: 'name', type: 'text' }], listViews: { org: { type: 'tree', tree: {} } } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/tree-without-parent-field', 'object "unit" › listViews.org', 'objects[0].listViews.org.tree.parentField'],
]);

// A self-lookup on the object → the renderer auto-detects → silent.
expect(validateFunctionalCompleteness({
objects: { unit: { fields: { parent: { type: 'lookup', reference: 'unit' } }, listViews: { org: { type: 'tree', tree: {} } } } },
})).toEqual([]);

// ADR-0047: the nested view's own `data.object` retargets the lookup, so
// the rule reads `cat`'s fields, not `unit`'s.
expect(validateFunctionalCompleteness({
objects: [
{ name: 'unit', fields: [], listViews: { org: { type: 'tree', tree: {}, data: { provider: 'object', object: 'cat' } } } },
{ name: 'cat', fields: [{ name: 'parent', type: 'tree' }] },
],
})).toEqual([]);
});

it('does not over-report: a nested view WITH its binding is silent', () => {
// The negative half of the pair. A widened walk that fired on complete
// metadata would be worse than the blind one it replaces.
expect(validateFunctionalCompleteness({
objects: [{
name: 'task',
fields: [{ name: 'title', type: 'text' }, { name: 'starts', type: 'datetime' }, { name: 'parent', type: 'lookup', reference: 'task' }],
list: { type: 'grid' },
listViews: {
plan: { type: 'gantt', gantt: { startDateField: 'starts', titleField: 'title' } },
org: { type: 'tree', tree: {} },
flat: { type: 'grid' },
},
}],
})).toEqual([]);
});

it('reports the nested and the top-level copy independently', () => {
// The two doors are separate authorable surfaces, not two readings of one
// definition — an author who wrote both gets a finding for each.
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], listViews: { plan: { type: 'gantt' } } }],
views: [{ object: 'task', listViews: { plan: { type: 'gantt' } } }],
}).map((f) => f.path).sort()).toEqual([
'objects[0].listViews.plan.gantt',
'views[0].listViews.plan.gantt',
]);
});

it('walks webhooks in both spellings', () => {
expect(validateFunctionalCompleteness({
webhooks: [{ name: 'notify', url: 'https://x' }],
Expand DownExpand Up@@ -167,6 +261,8 @@ describe('validateFunctionalCompleteness — the walk', () => {
undefined, null, 42, 'x', [], {},
{ objects: 'nope' }, { objects: [null, 7] },
{ objects: [{ name: 'o', fields: 'nope' }] },
{ objects: [{ name: 'o', list: null, listViews: 'nope' }] },
{ objects: { o: { list: 7, listViews: [null, 'x'] } } },
{ views: [{ list: null }] },
{ views: 'nope' },
{ webhooks: 'nope' }, { webhooks: [null, 7] },
Expand DownExpand Up@@ -244,3 +340,88 @@ describe('#14106 acceptance — timeline / map / tree bindings reach `validate`
});
}
});

/**
* The #14320 acceptance criteria, pinned end-to-end through the rule table on
* the card's own repro path (`runAuthoringRules('validate' | 'build', …)`) —
* because the walk being wired is only worth as much as the surface it reaches.
*
* The stack below authors nothing at `stack.views`: every list view lives on
* its object, the ADR-0017 spelling `os validate` used to walk right past.
*/
describe('#14320 acceptance — object-nested `list` / `listViews` reach `validate` AND `build`', () => {
const fields = {
subject: { type: 'text' },
last_update_at: { type: 'datetime' },
site: { type: 'text' },
};
const BINDING_RULES = ['view/layout-without-binding', 'view/tree-without-parent-field'];

/** Array-form objects — the `defineObject` spelling. */
const repro = {
objects: [{
name: 'duly_task',
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
}],
};

/** Map-form objects — the same stack, the other authorable spelling. */
const reproMap = {
objects: {
duly_task: {
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
},
},
};

const EXPECTED = [
'view/layout-without-binding @ objects[0].list.timeline',
'view/layout-without-binding @ objects[0].listViews.plan.gantt',
'view/layout-without-binding @ objects[0].listViews.sites.map',
'view/tree-without-parent-field @ objects[0].listViews.flat.tree.parentField',
];

const clean = {
objects: [{
name: 'duly_task',
fields: { ...fields, parent: { type: 'lookup', reference: 'duly_task' } },
list: { type: 'timeline', columns: ['subject'], timeline: { startDateField: 'last_update_at', titleField: 'subject' } },
listViews: {
plan: { type: 'gantt', columns: ['subject'], gantt: { startDateField: 'last_update_at', titleField: 'subject' } },
sites: { type: 'map', columns: ['subject'], map: { locationField: 'site' } },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
declared: { type: 'tree', tree: { parentField: 'parent' }, columns: ['subject'] },
},
}],
};

for (const command of ['validate', 'build'] as const) {
for (const [spelling, stack] of [['array-form', repro], ['map-form', reproMap]] as const) {
it(`the ${spelling} repro is diagnosed by \`${command}\``, () => {
const { advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack as never }));
const hits = advisories
.filter((f) => BINDING_RULES.includes(f.rule))
.map((f) => `${f.rule} @ ${f.path}`)
.sort();
expect(hits).toEqual(EXPECTED);
});
}

it(`the bound object-nested stack passes \`${command}\``, () => {
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: clean as never }));
expect([...errors, ...advisories].filter((f) => BINDING_RULES.includes(f.rule))).toEqual([]);
});
}
});
51 changes: 46 additions & 5 deletions packages/lint/src/validate-functional-completeness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,11 +116,13 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
// is `validate-object-references`' finding.
const objectsByName = new Map(entriesOf(stack.objects).map((o) => [o.name, o.def]));
const strName = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined => {
const own = isRec(view.data) ? strName(view.data.object) : undefined;
const name = own ?? strName(container.objectName) ?? strName(container.object);
return name ? objectsByName.get(name) : undefined;
};
const byName = (name: string | undefined): AnyRec | undefined =>
name ? objectsByName.get(name) : undefined;
/** A list view's own `data.object` retarget (ADR-0047), when it declares one. */
const ownObjectOf = (view: AnyRec): string | undefined =>
(isRec(view.data) ? strName(view.data.object) : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined =>
byName(ownObjectOf(view) ?? strName(container.objectName) ?? strName(container.object));
for (const [vi, container] of entriesOf(stack.views).entries()) {
const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`;
if (isRec(container.def.list)) {
Expand All@@ -141,6 +143,45 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
}
}

// ── List views: the OBJECT-NESTED container → objects[].list /
// objects[].listViews.* (ADR-0017 "Object has-many View") ─────────────
//
// The same two slots, reached through the other authorable door. An object
// carries its own view container — `lint-view-refs`'s `containerFromObject`
// pulls `list` / `listViews` straight off the object definition, and the
// ADR-0017 loader's `isAggregatedViewContainer` / `expandViewContainer`
// register the expansion — so a `timeline` / `gantt` / `tree` authored here
// reaches exactly the renderer the top-level container's copy reaches. The
// walk above stopped at `stack.views`, which made this gate report coverage
// it did not have: green, and blind to the other half of the stack.
//
// Bound object: the object the container belongs to, unless the view's own
// `data.object` retargets it (ADR-0047) — the resolution
// `validate-list-view-field-refs` uses on this identical rung
// (`listViewObject(lv) ?? objName`). Location grammar is this file's own,
// shared with the field walk directly above: a positional `objects[<i>]`
// (both spellings normalize to one ordered list) and the slot's authored
// key.
for (const [oi, obj] of entriesOf(stack.objects).entries()) {
const where = `object "${obj.name}"`;
if (isRec(obj.def.list)) {
push(
out,
checkViewCompleteness(obj.def.list, byName(ownObjectOf(obj.def.list) ?? obj.name)),
`${where} › list`,
`objects[${oi}].list`,
);
}
for (const lv of entriesOf(obj.def.listViews)) {
push(
out,
checkViewCompleteness(lv.def, byName(ownObjectOf(lv.def) ?? obj.name)),
`${where} › listViews.${lv.name}`,
`objects[${oi}].listViews${lv.key}`,
);
}
}

// ── Webhooks: stack.webhooks[] ─────────────────────────────────────────
// [ADR-0078 Phase 3] The one Tier-B candidate that survived its verification
// pass. A webhook materializes into `sys_webhook` and looks armed in Setup
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/eighty-pumas-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': patch
---

Walk object-nested `list` / `listViews.*` through the view completeness rules.

`validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
ADR-0017 "Object has-many View" spelling that `objects[].list` and
`objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
register the same expanded view items and reach the same renderer, so `os validate`
and `os build` were silent on exactly the half of the stack the sibling rules
(`lint-view-refs`, `validate-list-view-field-refs`) already walk.

Both authorable object spellings (array-form and name-keyed map) are covered, and a
list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
way it does on the top-level door — so `view/layout-without-binding` and
`view/tree-without-parent-field` now reach the nested door by construction rather
than by a second wiring step. Findings report as
`object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
181 changes: 181 additions & 0 deletions packages/lint/src/validate-functional-completeness.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,100 @@ describe('validateFunctionalCompleteness — the walk', () => {
})).toEqual([]);
});

// ── The OBJECT-NESTED door (ADR-0017 "Object has-many View") ────────────
// A container authored on the object itself — `objects[].list` /
// `objects[].listViews.*` — reaches the same renderer as the top-level
// `views[]` copy, so it must reach the same rules. The walk used to stop at
// `stack.views`, which is precisely the half-blind instrument this suite's
// docblock names.

it('walks an object-nested `listViews` view — ARRAY-form objects', () => {
const findings = validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [{ name: 'title', type: 'text' }], listViews: { plan: { type: 'gantt' } } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].severity).toBe('warning');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the same view when objects are name-keyed MAPS', () => {
// The fixture pair the card asks for: both authorable object spellings,
// one rule, one location grammar (the object segment is positional in
// both — the field walk above and `validate-list-view-field-refs` spell
// it the same way).
const findings = validateFunctionalCompleteness({
objects: { task: { fields: { title: { type: 'text' } }, listViews: { plan: { type: 'gantt' } } } },
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the object-nested default `list` slot too', () => {
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], list: { type: 'timeline' } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/layout-without-binding', 'object "task" › list', 'objects[0].list.timeline'],
]);
});

it('carries `view/tree-without-parent-field` through the nested door as well', () => {
// Not a second wiring step: the tree rule needs the BOUND OBJECT, and the
// nested door resolves it to the container's own object, so every rule the
// predicate has arrives here by construction.
expect(validateFunctionalCompleteness({
objects: [{ name: 'unit', fields: [{ name: 'name', type: 'text' }], listViews: { org: { type: 'tree', tree: {} } } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/tree-without-parent-field', 'object "unit" › listViews.org', 'objects[0].listViews.org.tree.parentField'],
]);

// A self-lookup on the object → the renderer auto-detects → silent.
expect(validateFunctionalCompleteness({
objects: { unit: { fields: { parent: { type: 'lookup', reference: 'unit' } }, listViews: { org: { type: 'tree', tree: {} } } } },
})).toEqual([]);

// ADR-0047: the nested view's own `data.object` retargets the lookup, so
// the rule reads `cat`'s fields, not `unit`'s.
expect(validateFunctionalCompleteness({
objects: [
{ name: 'unit', fields: [], listViews: { org: { type: 'tree', tree: {}, data: { provider: 'object', object: 'cat' } } } },
{ name: 'cat', fields: [{ name: 'parent', type: 'tree' }] },
],
})).toEqual([]);
});

it('does not over-report: a nested view WITH its binding is silent', () => {
// The negative half of the pair. A widened walk that fired on complete
// metadata would be worse than the blind one it replaces.
expect(validateFunctionalCompleteness({
objects: [{
name: 'task',
fields: [{ name: 'title', type: 'text' }, { name: 'starts', type: 'datetime' }, { name: 'parent', type: 'lookup', reference: 'task' }],
list: { type: 'grid' },
listViews: {
plan: { type: 'gantt', gantt: { startDateField: 'starts', titleField: 'title' } },
org: { type: 'tree', tree: {} },
flat: { type: 'grid' },
},
}],
})).toEqual([]);
});

it('reports the nested and the top-level copy independently', () => {
// The two doors are separate authorable surfaces, not two readings of one
// definition — an author who wrote both gets a finding for each.
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], listViews: { plan: { type: 'gantt' } } }],
views: [{ object: 'task', listViews: { plan: { type: 'gantt' } } }],
}).map((f) => f.path).sort()).toEqual([
'objects[0].listViews.plan.gantt',
'views[0].listViews.plan.gantt',
]);
});

it('walks webhooks in both spellings', () => {
expect(validateFunctionalCompleteness({
webhooks: [{ name: 'notify', url: 'https://x' }],
Expand DownExpand Up@@ -167,6 +261,8 @@ describe('validateFunctionalCompleteness — the walk', () => {
undefined, null, 42, 'x', [], {},
{ objects: 'nope' }, { objects: [null, 7] },
{ objects: [{ name: 'o', fields: 'nope' }] },
{ objects: [{ name: 'o', list: null, listViews: 'nope' }] },
{ objects: { o: { list: 7, listViews: [null, 'x'] } } },
{ views: [{ list: null }] },
{ views: 'nope' },
{ webhooks: 'nope' }, { webhooks: [null, 7] },
Expand DownExpand Up@@ -244,3 +340,88 @@ describe('#14106 acceptance — timeline / map / tree bindings reach `validate`
});
}
});

/**
* The #14320 acceptance criteria, pinned end-to-end through the rule table on
* the card's own repro path (`runAuthoringRules('validate' | 'build', …)`) —
* because the walk being wired is only worth as much as the surface it reaches.
*
* The stack below authors nothing at `stack.views`: every list view lives on
* its object, the ADR-0017 spelling `os validate` used to walk right past.
*/
describe('#14320 acceptance — object-nested `list` / `listViews` reach `validate` AND `build`', () => {
const fields = {
subject: { type: 'text' },
last_update_at: { type: 'datetime' },
site: { type: 'text' },
};
const BINDING_RULES = ['view/layout-without-binding', 'view/tree-without-parent-field'];

/** Array-form objects — the `defineObject` spelling. */
const repro = {
objects: [{
name: 'duly_task',
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
}],
};

/** Map-form objects — the same stack, the other authorable spelling. */
const reproMap = {
objects: {
duly_task: {
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
},
},
};

const EXPECTED = [
'view/layout-without-binding @ objects[0].list.timeline',
'view/layout-without-binding @ objects[0].listViews.plan.gantt',
'view/layout-without-binding @ objects[0].listViews.sites.map',
'view/tree-without-parent-field @ objects[0].listViews.flat.tree.parentField',
];

const clean = {
objects: [{
name: 'duly_task',
fields: { ...fields, parent: { type: 'lookup', reference: 'duly_task' } },
list: { type: 'timeline', columns: ['subject'], timeline: { startDateField: 'last_update_at', titleField: 'subject' } },
listViews: {
plan: { type: 'gantt', columns: ['subject'], gantt: { startDateField: 'last_update_at', titleField: 'subject' } },
sites: { type: 'map', columns: ['subject'], map: { locationField: 'site' } },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
declared: { type: 'tree', tree: { parentField: 'parent' }, columns: ['subject'] },
},
}],
};

for (const command of ['validate', 'build'] as const) {
for (const [spelling, stack] of [['array-form', repro], ['map-form', reproMap]] as const) {
it(`the ${spelling} repro is diagnosed by \`${command}\``, () => {
const { advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack as never }));
const hits = advisories
.filter((f) => BINDING_RULES.includes(f.rule))
.map((f) => `${f.rule} @ ${f.path}`)
.sort();
expect(hits).toEqual(EXPECTED);
});
}

it(`the bound object-nested stack passes \`${command}\``, () => {
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: clean as never }));
expect([...errors, ...advisories].filter((f) => BINDING_RULES.includes(f.rule))).toEqual([]);
});
}
});
51 changes: 46 additions & 5 deletions packages/lint/src/validate-functional-completeness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,11 +116,13 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
// is `validate-object-references`' finding.
const objectsByName = new Map(entriesOf(stack.objects).map((o) => [o.name, o.def]));
const strName = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined => {
const own = isRec(view.data) ? strName(view.data.object) : undefined;
const name = own ?? strName(container.objectName) ?? strName(container.object);
return name ? objectsByName.get(name) : undefined;
};
const byName = (name: string | undefined): AnyRec | undefined =>
name ? objectsByName.get(name) : undefined;
/** A list view's own `data.object` retarget (ADR-0047), when it declares one. */
const ownObjectOf = (view: AnyRec): string | undefined =>
(isRec(view.data) ? strName(view.data.object) : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined =>
byName(ownObjectOf(view) ?? strName(container.objectName) ?? strName(container.object));
for (const [vi, container] of entriesOf(stack.views).entries()) {
const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`;
if (isRec(container.def.list)) {
Expand All@@ -141,6 +143,45 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
}
}

// ── List views: the OBJECT-NESTED container → objects[].list /
// objects[].listViews.* (ADR-0017 "Object has-many View") ─────────────
//
// The same two slots, reached through the other authorable door. An object
// carries its own view container — `lint-view-refs`'s `containerFromObject`
// pulls `list` / `listViews` straight off the object definition, and the
// ADR-0017 loader's `isAggregatedViewContainer` / `expandViewContainer`
// register the expansion — so a `timeline` / `gantt` / `tree` authored here
// reaches exactly the renderer the top-level container's copy reaches. The
// walk above stopped at `stack.views`, which made this gate report coverage
// it did not have: green, and blind to the other half of the stack.
//
// Bound object: the object the container belongs to, unless the view's own
// `data.object` retargets it (ADR-0047) — the resolution
// `validate-list-view-field-refs` uses on this identical rung
// (`listViewObject(lv) ?? objName`). Location grammar is this file's own,
// shared with the field walk directly above: a positional `objects[<i>]`
// (both spellings normalize to one ordered list) and the slot's authored
// key.
for (const [oi, obj] of entriesOf(stack.objects).entries()) {
const where = `object "${obj.name}"`;
if (isRec(obj.def.list)) {
push(
out,
checkViewCompleteness(obj.def.list, byName(ownObjectOf(obj.def.list) ?? obj.name)),
`${where} › list`,
`objects[${oi}].list`,
);
}
for (const lv of entriesOf(obj.def.listViews)) {
push(
out,
checkViewCompleteness(lv.def, byName(ownObjectOf(lv.def) ?? obj.name)),
`${where} › listViews.${lv.name}`,
`objects[${oi}].listViews${lv.key}`,
);
}
}

// ── Webhooks: stack.webhooks[] ─────────────────────────────────────────
// [ADR-0078 Phase 3] The one Tier-B candidate that survived its verification
// pass. A webhook materializes into `sys_webhook` and looks armed in Setup
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/eighty-pumas-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': patch
---

Walk object-nested `list` / `listViews.*` through the view completeness rules.

`validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
ADR-0017 "Object has-many View" spelling that `objects[].list` and
`objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
register the same expanded view items and reach the same renderer, so `os validate`
and `os build` were silent on exactly the half of the stack the sibling rules
(`lint-view-refs`, `validate-list-view-field-refs`) already walk.

Both authorable object spellings (array-form and name-keyed map) are covered, and a
list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
way it does on the top-level door — so `view/layout-without-binding` and
`view/tree-without-parent-field` now reach the nested door by construction rather
than by a second wiring step. Findings report as
`object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
181 changes: 181 additions & 0 deletions packages/lint/src/validate-functional-completeness.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,100 @@ describe('validateFunctionalCompleteness — the walk', () => {
})).toEqual([]);
});

// ── The OBJECT-NESTED door (ADR-0017 "Object has-many View") ────────────
// A container authored on the object itself — `objects[].list` /
// `objects[].listViews.*` — reaches the same renderer as the top-level
// `views[]` copy, so it must reach the same rules. The walk used to stop at
// `stack.views`, which is precisely the half-blind instrument this suite's
// docblock names.

it('walks an object-nested `listViews` view — ARRAY-form objects', () => {
const findings = validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [{ name: 'title', type: 'text' }], listViews: { plan: { type: 'gantt' } } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].severity).toBe('warning');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the same view when objects are name-keyed MAPS', () => {
// The fixture pair the card asks for: both authorable object spellings,
// one rule, one location grammar (the object segment is positional in
// both — the field walk above and `validate-list-view-field-refs` spell
// it the same way).
const findings = validateFunctionalCompleteness({
objects: { task: { fields: { title: { type: 'text' } }, listViews: { plan: { type: 'gantt' } } } },
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the object-nested default `list` slot too', () => {
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], list: { type: 'timeline' } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/layout-without-binding', 'object "task" › list', 'objects[0].list.timeline'],
]);
});

it('carries `view/tree-without-parent-field` through the nested door as well', () => {
// Not a second wiring step: the tree rule needs the BOUND OBJECT, and the
// nested door resolves it to the container's own object, so every rule the
// predicate has arrives here by construction.
expect(validateFunctionalCompleteness({
objects: [{ name: 'unit', fields: [{ name: 'name', type: 'text' }], listViews: { org: { type: 'tree', tree: {} } } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/tree-without-parent-field', 'object "unit" › listViews.org', 'objects[0].listViews.org.tree.parentField'],
]);

// A self-lookup on the object → the renderer auto-detects → silent.
expect(validateFunctionalCompleteness({
objects: { unit: { fields: { parent: { type: 'lookup', reference: 'unit' } }, listViews: { org: { type: 'tree', tree: {} } } } },
})).toEqual([]);

// ADR-0047: the nested view's own `data.object` retargets the lookup, so
// the rule reads `cat`'s fields, not `unit`'s.
expect(validateFunctionalCompleteness({
objects: [
{ name: 'unit', fields: [], listViews: { org: { type: 'tree', tree: {}, data: { provider: 'object', object: 'cat' } } } },
{ name: 'cat', fields: [{ name: 'parent', type: 'tree' }] },
],
})).toEqual([]);
});

it('does not over-report: a nested view WITH its binding is silent', () => {
// The negative half of the pair. A widened walk that fired on complete
// metadata would be worse than the blind one it replaces.
expect(validateFunctionalCompleteness({
objects: [{
name: 'task',
fields: [{ name: 'title', type: 'text' }, { name: 'starts', type: 'datetime' }, { name: 'parent', type: 'lookup', reference: 'task' }],
list: { type: 'grid' },
listViews: {
plan: { type: 'gantt', gantt: { startDateField: 'starts', titleField: 'title' } },
org: { type: 'tree', tree: {} },
flat: { type: 'grid' },
},
}],
})).toEqual([]);
});

it('reports the nested and the top-level copy independently', () => {
// The two doors are separate authorable surfaces, not two readings of one
// definition — an author who wrote both gets a finding for each.
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], listViews: { plan: { type: 'gantt' } } }],
views: [{ object: 'task', listViews: { plan: { type: 'gantt' } } }],
}).map((f) => f.path).sort()).toEqual([
'objects[0].listViews.plan.gantt',
'views[0].listViews.plan.gantt',
]);
});

it('walks webhooks in both spellings', () => {
expect(validateFunctionalCompleteness({
webhooks: [{ name: 'notify', url: 'https://x' }],
Expand DownExpand Up@@ -167,6 +261,8 @@ describe('validateFunctionalCompleteness — the walk', () => {
undefined, null, 42, 'x', [], {},
{ objects: 'nope' }, { objects: [null, 7] },
{ objects: [{ name: 'o', fields: 'nope' }] },
{ objects: [{ name: 'o', list: null, listViews: 'nope' }] },
{ objects: { o: { list: 7, listViews: [null, 'x'] } } },
{ views: [{ list: null }] },
{ views: 'nope' },
{ webhooks: 'nope' }, { webhooks: [null, 7] },
Expand DownExpand Up@@ -244,3 +340,88 @@ describe('#14106 acceptance — timeline / map / tree bindings reach `validate`
});
}
});

/**
* The #14320 acceptance criteria, pinned end-to-end through the rule table on
* the card's own repro path (`runAuthoringRules('validate' | 'build', …)`) —
* because the walk being wired is only worth as much as the surface it reaches.
*
* The stack below authors nothing at `stack.views`: every list view lives on
* its object, the ADR-0017 spelling `os validate` used to walk right past.
*/
describe('#14320 acceptance — object-nested `list` / `listViews` reach `validate` AND `build`', () => {
const fields = {
subject: { type: 'text' },
last_update_at: { type: 'datetime' },
site: { type: 'text' },
};
const BINDING_RULES = ['view/layout-without-binding', 'view/tree-without-parent-field'];

/** Array-form objects — the `defineObject` spelling. */
const repro = {
objects: [{
name: 'duly_task',
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
}],
};

/** Map-form objects — the same stack, the other authorable spelling. */
const reproMap = {
objects: {
duly_task: {
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
},
},
};

const EXPECTED = [
'view/layout-without-binding @ objects[0].list.timeline',
'view/layout-without-binding @ objects[0].listViews.plan.gantt',
'view/layout-without-binding @ objects[0].listViews.sites.map',
'view/tree-without-parent-field @ objects[0].listViews.flat.tree.parentField',
];

const clean = {
objects: [{
name: 'duly_task',
fields: { ...fields, parent: { type: 'lookup', reference: 'duly_task' } },
list: { type: 'timeline', columns: ['subject'], timeline: { startDateField: 'last_update_at', titleField: 'subject' } },
listViews: {
plan: { type: 'gantt', columns: ['subject'], gantt: { startDateField: 'last_update_at', titleField: 'subject' } },
sites: { type: 'map', columns: ['subject'], map: { locationField: 'site' } },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
declared: { type: 'tree', tree: { parentField: 'parent' }, columns: ['subject'] },
},
}],
};

for (const command of ['validate', 'build'] as const) {
for (const [spelling, stack] of [['array-form', repro], ['map-form', reproMap]] as const) {
it(`the ${spelling} repro is diagnosed by \`${command}\``, () => {
const { advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack as never }));
const hits = advisories
.filter((f) => BINDING_RULES.includes(f.rule))
.map((f) => `${f.rule} @ ${f.path}`)
.sort();
expect(hits).toEqual(EXPECTED);
});
}

it(`the bound object-nested stack passes \`${command}\``, () => {
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: clean as never }));
expect([...errors, ...advisories].filter((f) => BINDING_RULES.includes(f.rule))).toEqual([]);
});
}
});
51 changes: 46 additions & 5 deletions packages/lint/src/validate-functional-completeness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,11 +116,13 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
// is `validate-object-references`' finding.
const objectsByName = new Map(entriesOf(stack.objects).map((o) => [o.name, o.def]));
const strName = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined => {
const own = isRec(view.data) ? strName(view.data.object) : undefined;
const name = own ?? strName(container.objectName) ?? strName(container.object);
return name ? objectsByName.get(name) : undefined;
};
const byName = (name: string | undefined): AnyRec | undefined =>
name ? objectsByName.get(name) : undefined;
/** A list view's own `data.object` retarget (ADR-0047), when it declares one. */
const ownObjectOf = (view: AnyRec): string | undefined =>
(isRec(view.data) ? strName(view.data.object) : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined =>
byName(ownObjectOf(view) ?? strName(container.objectName) ?? strName(container.object));
for (const [vi, container] of entriesOf(stack.views).entries()) {
const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`;
if (isRec(container.def.list)) {
Expand All@@ -141,6 +143,45 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
}
}

// ── List views: the OBJECT-NESTED container → objects[].list /
// objects[].listViews.* (ADR-0017 "Object has-many View") ─────────────
//
// The same two slots, reached through the other authorable door. An object
// carries its own view container — `lint-view-refs`'s `containerFromObject`
// pulls `list` / `listViews` straight off the object definition, and the
// ADR-0017 loader's `isAggregatedViewContainer` / `expandViewContainer`
// register the expansion — so a `timeline` / `gantt` / `tree` authored here
// reaches exactly the renderer the top-level container's copy reaches. The
// walk above stopped at `stack.views`, which made this gate report coverage
// it did not have: green, and blind to the other half of the stack.
//
// Bound object: the object the container belongs to, unless the view's own
// `data.object` retargets it (ADR-0047) — the resolution
// `validate-list-view-field-refs` uses on this identical rung
// (`listViewObject(lv) ?? objName`). Location grammar is this file's own,
// shared with the field walk directly above: a positional `objects[<i>]`
// (both spellings normalize to one ordered list) and the slot's authored
// key.
for (const [oi, obj] of entriesOf(stack.objects).entries()) {
const where = `object "${obj.name}"`;
if (isRec(obj.def.list)) {
push(
out,
checkViewCompleteness(obj.def.list, byName(ownObjectOf(obj.def.list) ?? obj.name)),
`${where} › list`,
`objects[${oi}].list`,
);
}
for (const lv of entriesOf(obj.def.listViews)) {
push(
out,
checkViewCompleteness(lv.def, byName(ownObjectOf(lv.def) ?? obj.name)),
`${where} › listViews.${lv.name}`,
`objects[${oi}].listViews${lv.key}`,
);
}
}

// ── Webhooks: stack.webhooks[] ─────────────────────────────────────────
// [ADR-0078 Phase 3] The one Tier-B candidate that survived its verification
// pass. A webhook materializes into `sys_webhook` and looks armed in Setup
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/eighty-pumas-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': patch
---

Walk object-nested `list` / `listViews.*` through the view completeness rules.

`validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
ADR-0017 "Object has-many View" spelling that `objects[].list` and
`objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
register the same expanded view items and reach the same renderer, so `os validate`
and `os build` were silent on exactly the half of the stack the sibling rules
(`lint-view-refs`, `validate-list-view-field-refs`) already walk.

Both authorable object spellings (array-form and name-keyed map) are covered, and a
list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
way it does on the top-level door — so `view/layout-without-binding` and
`view/tree-without-parent-field` now reach the nested door by construction rather
than by a second wiring step. Findings report as
`object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
181 changes: 181 additions & 0 deletions packages/lint/src/validate-functional-completeness.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,100 @@ describe('validateFunctionalCompleteness — the walk', () => {
})).toEqual([]);
});

// ── The OBJECT-NESTED door (ADR-0017 "Object has-many View") ────────────
// A container authored on the object itself — `objects[].list` /
// `objects[].listViews.*` — reaches the same renderer as the top-level
// `views[]` copy, so it must reach the same rules. The walk used to stop at
// `stack.views`, which is precisely the half-blind instrument this suite's
// docblock names.

it('walks an object-nested `listViews` view — ARRAY-form objects', () => {
const findings = validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [{ name: 'title', type: 'text' }], listViews: { plan: { type: 'gantt' } } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].severity).toBe('warning');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the same view when objects are name-keyed MAPS', () => {
// The fixture pair the card asks for: both authorable object spellings,
// one rule, one location grammar (the object segment is positional in
// both — the field walk above and `validate-list-view-field-refs` spell
// it the same way).
const findings = validateFunctionalCompleteness({
objects: { task: { fields: { title: { type: 'text' } }, listViews: { plan: { type: 'gantt' } } } },
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the object-nested default `list` slot too', () => {
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], list: { type: 'timeline' } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/layout-without-binding', 'object "task" › list', 'objects[0].list.timeline'],
]);
});

it('carries `view/tree-without-parent-field` through the nested door as well', () => {
// Not a second wiring step: the tree rule needs the BOUND OBJECT, and the
// nested door resolves it to the container's own object, so every rule the
// predicate has arrives here by construction.
expect(validateFunctionalCompleteness({
objects: [{ name: 'unit', fields: [{ name: 'name', type: 'text' }], listViews: { org: { type: 'tree', tree: {} } } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/tree-without-parent-field', 'object "unit" › listViews.org', 'objects[0].listViews.org.tree.parentField'],
]);

// A self-lookup on the object → the renderer auto-detects → silent.
expect(validateFunctionalCompleteness({
objects: { unit: { fields: { parent: { type: 'lookup', reference: 'unit' } }, listViews: { org: { type: 'tree', tree: {} } } } },
})).toEqual([]);

// ADR-0047: the nested view's own `data.object` retargets the lookup, so
// the rule reads `cat`'s fields, not `unit`'s.
expect(validateFunctionalCompleteness({
objects: [
{ name: 'unit', fields: [], listViews: { org: { type: 'tree', tree: {}, data: { provider: 'object', object: 'cat' } } } },
{ name: 'cat', fields: [{ name: 'parent', type: 'tree' }] },
],
})).toEqual([]);
});

it('does not over-report: a nested view WITH its binding is silent', () => {
// The negative half of the pair. A widened walk that fired on complete
// metadata would be worse than the blind one it replaces.
expect(validateFunctionalCompleteness({
objects: [{
name: 'task',
fields: [{ name: 'title', type: 'text' }, { name: 'starts', type: 'datetime' }, { name: 'parent', type: 'lookup', reference: 'task' }],
list: { type: 'grid' },
listViews: {
plan: { type: 'gantt', gantt: { startDateField: 'starts', titleField: 'title' } },
org: { type: 'tree', tree: {} },
flat: { type: 'grid' },
},
}],
})).toEqual([]);
});

it('reports the nested and the top-level copy independently', () => {
// The two doors are separate authorable surfaces, not two readings of one
// definition — an author who wrote both gets a finding for each.
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], listViews: { plan: { type: 'gantt' } } }],
views: [{ object: 'task', listViews: { plan: { type: 'gantt' } } }],
}).map((f) => f.path).sort()).toEqual([
'objects[0].listViews.plan.gantt',
'views[0].listViews.plan.gantt',
]);
});

it('walks webhooks in both spellings', () => {
expect(validateFunctionalCompleteness({
webhooks: [{ name: 'notify', url: 'https://x' }],
Expand DownExpand Up@@ -167,6 +261,8 @@ describe('validateFunctionalCompleteness — the walk', () => {
undefined, null, 42, 'x', [], {},
{ objects: 'nope' }, { objects: [null, 7] },
{ objects: [{ name: 'o', fields: 'nope' }] },
{ objects: [{ name: 'o', list: null, listViews: 'nope' }] },
{ objects: { o: { list: 7, listViews: [null, 'x'] } } },
{ views: [{ list: null }] },
{ views: 'nope' },
{ webhooks: 'nope' }, { webhooks: [null, 7] },
Expand DownExpand Up@@ -244,3 +340,88 @@ describe('#14106 acceptance — timeline / map / tree bindings reach `validate`
});
}
});

/**
* The #14320 acceptance criteria, pinned end-to-end through the rule table on
* the card's own repro path (`runAuthoringRules('validate' | 'build', …)`) —
* because the walk being wired is only worth as much as the surface it reaches.
*
* The stack below authors nothing at `stack.views`: every list view lives on
* its object, the ADR-0017 spelling `os validate` used to walk right past.
*/
describe('#14320 acceptance — object-nested `list` / `listViews` reach `validate` AND `build`', () => {
const fields = {
subject: { type: 'text' },
last_update_at: { type: 'datetime' },
site: { type: 'text' },
};
const BINDING_RULES = ['view/layout-without-binding', 'view/tree-without-parent-field'];

/** Array-form objects — the `defineObject` spelling. */
const repro = {
objects: [{
name: 'duly_task',
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
}],
};

/** Map-form objects — the same stack, the other authorable spelling. */
const reproMap = {
objects: {
duly_task: {
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
},
},
};

const EXPECTED = [
'view/layout-without-binding @ objects[0].list.timeline',
'view/layout-without-binding @ objects[0].listViews.plan.gantt',
'view/layout-without-binding @ objects[0].listViews.sites.map',
'view/tree-without-parent-field @ objects[0].listViews.flat.tree.parentField',
];

const clean = {
objects: [{
name: 'duly_task',
fields: { ...fields, parent: { type: 'lookup', reference: 'duly_task' } },
list: { type: 'timeline', columns: ['subject'], timeline: { startDateField: 'last_update_at', titleField: 'subject' } },
listViews: {
plan: { type: 'gantt', columns: ['subject'], gantt: { startDateField: 'last_update_at', titleField: 'subject' } },
sites: { type: 'map', columns: ['subject'], map: { locationField: 'site' } },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
declared: { type: 'tree', tree: { parentField: 'parent' }, columns: ['subject'] },
},
}],
};

for (const command of ['validate', 'build'] as const) {
for (const [spelling, stack] of [['array-form', repro], ['map-form', reproMap]] as const) {
it(`the ${spelling} repro is diagnosed by \`${command}\``, () => {
const { advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack as never }));
const hits = advisories
.filter((f) => BINDING_RULES.includes(f.rule))
.map((f) => `${f.rule} @ ${f.path}`)
.sort();
expect(hits).toEqual(EXPECTED);
});
}

it(`the bound object-nested stack passes \`${command}\``, () => {
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: clean as never }));
expect([...errors, ...advisories].filter((f) => BINDING_RULES.includes(f.rule))).toEqual([]);
});
}
});
51 changes: 46 additions & 5 deletions packages/lint/src/validate-functional-completeness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,11 +116,13 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
// is `validate-object-references`' finding.
const objectsByName = new Map(entriesOf(stack.objects).map((o) => [o.name, o.def]));
const strName = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined => {
const own = isRec(view.data) ? strName(view.data.object) : undefined;
const name = own ?? strName(container.objectName) ?? strName(container.object);
return name ? objectsByName.get(name) : undefined;
};
const byName = (name: string | undefined): AnyRec | undefined =>
name ? objectsByName.get(name) : undefined;
/** A list view's own `data.object` retarget (ADR-0047), when it declares one. */
const ownObjectOf = (view: AnyRec): string | undefined =>
(isRec(view.data) ? strName(view.data.object) : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined =>
byName(ownObjectOf(view) ?? strName(container.objectName) ?? strName(container.object));
for (const [vi, container] of entriesOf(stack.views).entries()) {
const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`;
if (isRec(container.def.list)) {
Expand All@@ -141,6 +143,45 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
}
}

// ── List views: the OBJECT-NESTED container → objects[].list /
// objects[].listViews.* (ADR-0017 "Object has-many View") ─────────────
//
// The same two slots, reached through the other authorable door. An object
// carries its own view container — `lint-view-refs`'s `containerFromObject`
// pulls `list` / `listViews` straight off the object definition, and the
// ADR-0017 loader's `isAggregatedViewContainer` / `expandViewContainer`
// register the expansion — so a `timeline` / `gantt` / `tree` authored here
// reaches exactly the renderer the top-level container's copy reaches. The
// walk above stopped at `stack.views`, which made this gate report coverage
// it did not have: green, and blind to the other half of the stack.
//
// Bound object: the object the container belongs to, unless the view's own
// `data.object` retargets it (ADR-0047) — the resolution
// `validate-list-view-field-refs` uses on this identical rung
// (`listViewObject(lv) ?? objName`). Location grammar is this file's own,
// shared with the field walk directly above: a positional `objects[<i>]`
// (both spellings normalize to one ordered list) and the slot's authored
// key.
for (const [oi, obj] of entriesOf(stack.objects).entries()) {
const where = `object "${obj.name}"`;
if (isRec(obj.def.list)) {
push(
out,
checkViewCompleteness(obj.def.list, byName(ownObjectOf(obj.def.list) ?? obj.name)),
`${where} › list`,
`objects[${oi}].list`,
);
}
for (const lv of entriesOf(obj.def.listViews)) {
push(
out,
checkViewCompleteness(lv.def, byName(ownObjectOf(lv.def) ?? obj.name)),
`${where} › listViews.${lv.name}`,
`objects[${oi}].listViews${lv.key}`,
);
}
}

// ── Webhooks: stack.webhooks[] ─────────────────────────────────────────
// [ADR-0078 Phase 3] The one Tier-B candidate that survived its verification
// pass. A webhook materializes into `sys_webhook` and looks armed in Setup
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/eighty-pumas-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': patch
---

Walk object-nested `list` / `listViews.*` through the view completeness rules.

`validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
ADR-0017 "Object has-many View" spelling that `objects[].list` and
`objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
register the same expanded view items and reach the same renderer, so `os validate`
and `os build` were silent on exactly the half of the stack the sibling rules
(`lint-view-refs`, `validate-list-view-field-refs`) already walk.

Both authorable object spellings (array-form and name-keyed map) are covered, and a
list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
way it does on the top-level door — so `view/layout-without-binding` and
`view/tree-without-parent-field` now reach the nested door by construction rather
than by a second wiring step. Findings report as
`object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
181 changes: 181 additions & 0 deletions packages/lint/src/validate-functional-completeness.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,100 @@ describe('validateFunctionalCompleteness — the walk', () => {
})).toEqual([]);
});

// ── The OBJECT-NESTED door (ADR-0017 "Object has-many View") ────────────
// A container authored on the object itself — `objects[].list` /
// `objects[].listViews.*` — reaches the same renderer as the top-level
// `views[]` copy, so it must reach the same rules. The walk used to stop at
// `stack.views`, which is precisely the half-blind instrument this suite's
// docblock names.

it('walks an object-nested `listViews` view — ARRAY-form objects', () => {
const findings = validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [{ name: 'title', type: 'text' }], listViews: { plan: { type: 'gantt' } } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].severity).toBe('warning');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the same view when objects are name-keyed MAPS', () => {
// The fixture pair the card asks for: both authorable object spellings,
// one rule, one location grammar (the object segment is positional in
// both — the field walk above and `validate-list-view-field-refs` spell
// it the same way).
const findings = validateFunctionalCompleteness({
objects: { task: { fields: { title: { type: 'text' } }, listViews: { plan: { type: 'gantt' } } } },
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the object-nested default `list` slot too', () => {
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], list: { type: 'timeline' } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/layout-without-binding', 'object "task" › list', 'objects[0].list.timeline'],
]);
});

it('carries `view/tree-without-parent-field` through the nested door as well', () => {
// Not a second wiring step: the tree rule needs the BOUND OBJECT, and the
// nested door resolves it to the container's own object, so every rule the
// predicate has arrives here by construction.
expect(validateFunctionalCompleteness({
objects: [{ name: 'unit', fields: [{ name: 'name', type: 'text' }], listViews: { org: { type: 'tree', tree: {} } } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/tree-without-parent-field', 'object "unit" › listViews.org', 'objects[0].listViews.org.tree.parentField'],
]);

// A self-lookup on the object → the renderer auto-detects → silent.
expect(validateFunctionalCompleteness({
objects: { unit: { fields: { parent: { type: 'lookup', reference: 'unit' } }, listViews: { org: { type: 'tree', tree: {} } } } },
})).toEqual([]);

// ADR-0047: the nested view's own `data.object` retargets the lookup, so
// the rule reads `cat`'s fields, not `unit`'s.
expect(validateFunctionalCompleteness({
objects: [
{ name: 'unit', fields: [], listViews: { org: { type: 'tree', tree: {}, data: { provider: 'object', object: 'cat' } } } },
{ name: 'cat', fields: [{ name: 'parent', type: 'tree' }] },
],
})).toEqual([]);
});

it('does not over-report: a nested view WITH its binding is silent', () => {
// The negative half of the pair. A widened walk that fired on complete
// metadata would be worse than the blind one it replaces.
expect(validateFunctionalCompleteness({
objects: [{
name: 'task',
fields: [{ name: 'title', type: 'text' }, { name: 'starts', type: 'datetime' }, { name: 'parent', type: 'lookup', reference: 'task' }],
list: { type: 'grid' },
listViews: {
plan: { type: 'gantt', gantt: { startDateField: 'starts', titleField: 'title' } },
org: { type: 'tree', tree: {} },
flat: { type: 'grid' },
},
}],
})).toEqual([]);
});

it('reports the nested and the top-level copy independently', () => {
// The two doors are separate authorable surfaces, not two readings of one
// definition — an author who wrote both gets a finding for each.
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], listViews: { plan: { type: 'gantt' } } }],
views: [{ object: 'task', listViews: { plan: { type: 'gantt' } } }],
}).map((f) => f.path).sort()).toEqual([
'objects[0].listViews.plan.gantt',
'views[0].listViews.plan.gantt',
]);
});

it('walks webhooks in both spellings', () => {
expect(validateFunctionalCompleteness({
webhooks: [{ name: 'notify', url: 'https://x' }],
Expand DownExpand Up@@ -167,6 +261,8 @@ describe('validateFunctionalCompleteness — the walk', () => {
undefined, null, 42, 'x', [], {},
{ objects: 'nope' }, { objects: [null, 7] },
{ objects: [{ name: 'o', fields: 'nope' }] },
{ objects: [{ name: 'o', list: null, listViews: 'nope' }] },
{ objects: { o: { list: 7, listViews: [null, 'x'] } } },
{ views: [{ list: null }] },
{ views: 'nope' },
{ webhooks: 'nope' }, { webhooks: [null, 7] },
Expand DownExpand Up@@ -244,3 +340,88 @@ describe('#14106 acceptance — timeline / map / tree bindings reach `validate`
});
}
});

/**
* The #14320 acceptance criteria, pinned end-to-end through the rule table on
* the card's own repro path (`runAuthoringRules('validate' | 'build', …)`) —
* because the walk being wired is only worth as much as the surface it reaches.
*
* The stack below authors nothing at `stack.views`: every list view lives on
* its object, the ADR-0017 spelling `os validate` used to walk right past.
*/
describe('#14320 acceptance — object-nested `list` / `listViews` reach `validate` AND `build`', () => {
const fields = {
subject: { type: 'text' },
last_update_at: { type: 'datetime' },
site: { type: 'text' },
};
const BINDING_RULES = ['view/layout-without-binding', 'view/tree-without-parent-field'];

/** Array-form objects — the `defineObject` spelling. */
const repro = {
objects: [{
name: 'duly_task',
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
}],
};

/** Map-form objects — the same stack, the other authorable spelling. */
const reproMap = {
objects: {
duly_task: {
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
},
},
};

const EXPECTED = [
'view/layout-without-binding @ objects[0].list.timeline',
'view/layout-without-binding @ objects[0].listViews.plan.gantt',
'view/layout-without-binding @ objects[0].listViews.sites.map',
'view/tree-without-parent-field @ objects[0].listViews.flat.tree.parentField',
];

const clean = {
objects: [{
name: 'duly_task',
fields: { ...fields, parent: { type: 'lookup', reference: 'duly_task' } },
list: { type: 'timeline', columns: ['subject'], timeline: { startDateField: 'last_update_at', titleField: 'subject' } },
listViews: {
plan: { type: 'gantt', columns: ['subject'], gantt: { startDateField: 'last_update_at', titleField: 'subject' } },
sites: { type: 'map', columns: ['subject'], map: { locationField: 'site' } },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
declared: { type: 'tree', tree: { parentField: 'parent' }, columns: ['subject'] },
},
}],
};

for (const command of ['validate', 'build'] as const) {
for (const [spelling, stack] of [['array-form', repro], ['map-form', reproMap]] as const) {
it(`the ${spelling} repro is diagnosed by \`${command}\``, () => {
const { advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack as never }));
const hits = advisories
.filter((f) => BINDING_RULES.includes(f.rule))
.map((f) => `${f.rule} @ ${f.path}`)
.sort();
expect(hits).toEqual(EXPECTED);
});
}

it(`the bound object-nested stack passes \`${command}\``, () => {
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: clean as never }));
expect([...errors, ...advisories].filter((f) => BINDING_RULES.includes(f.rule))).toEqual([]);
});
}
});
51 changes: 46 additions & 5 deletions packages/lint/src/validate-functional-completeness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,11 +116,13 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
// is `validate-object-references`' finding.
const objectsByName = new Map(entriesOf(stack.objects).map((o) => [o.name, o.def]));
const strName = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined => {
const own = isRec(view.data) ? strName(view.data.object) : undefined;
const name = own ?? strName(container.objectName) ?? strName(container.object);
return name ? objectsByName.get(name) : undefined;
};
const byName = (name: string | undefined): AnyRec | undefined =>
name ? objectsByName.get(name) : undefined;
/** A list view's own `data.object` retarget (ADR-0047), when it declares one. */
const ownObjectOf = (view: AnyRec): string | undefined =>
(isRec(view.data) ? strName(view.data.object) : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined =>
byName(ownObjectOf(view) ?? strName(container.objectName) ?? strName(container.object));
for (const [vi, container] of entriesOf(stack.views).entries()) {
const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`;
if (isRec(container.def.list)) {
Expand All@@ -141,6 +143,45 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
}
}

// ── List views: the OBJECT-NESTED container → objects[].list /
// objects[].listViews.* (ADR-0017 "Object has-many View") ─────────────
//
// The same two slots, reached through the other authorable door. An object
// carries its own view container — `lint-view-refs`'s `containerFromObject`
// pulls `list` / `listViews` straight off the object definition, and the
// ADR-0017 loader's `isAggregatedViewContainer` / `expandViewContainer`
// register the expansion — so a `timeline` / `gantt` / `tree` authored here
// reaches exactly the renderer the top-level container's copy reaches. The
// walk above stopped at `stack.views`, which made this gate report coverage
// it did not have: green, and blind to the other half of the stack.
//
// Bound object: the object the container belongs to, unless the view's own
// `data.object` retargets it (ADR-0047) — the resolution
// `validate-list-view-field-refs` uses on this identical rung
// (`listViewObject(lv) ?? objName`). Location grammar is this file's own,
// shared with the field walk directly above: a positional `objects[<i>]`
// (both spellings normalize to one ordered list) and the slot's authored
// key.
for (const [oi, obj] of entriesOf(stack.objects).entries()) {
const where = `object "${obj.name}"`;
if (isRec(obj.def.list)) {
push(
out,
checkViewCompleteness(obj.def.list, byName(ownObjectOf(obj.def.list) ?? obj.name)),
`${where} › list`,
`objects[${oi}].list`,
);
}
for (const lv of entriesOf(obj.def.listViews)) {
push(
out,
checkViewCompleteness(lv.def, byName(ownObjectOf(lv.def) ?? obj.name)),
`${where} › listViews.${lv.name}`,
`objects[${oi}].listViews${lv.key}`,
);
}
}

// ── Webhooks: stack.webhooks[] ─────────────────────────────────────────
// [ADR-0078 Phase 3] The one Tier-B candidate that survived its verification
// pass. A webhook materializes into `sys_webhook` and looks armed in Setup
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/eighty-pumas-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': patch
---

Walk object-nested `list` / `listViews.*` through the view completeness rules.

`validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
ADR-0017 "Object has-many View" spelling that `objects[].list` and
`objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
register the same expanded view items and reach the same renderer, so `os validate`
and `os build` were silent on exactly the half of the stack the sibling rules
(`lint-view-refs`, `validate-list-view-field-refs`) already walk.

Both authorable object spellings (array-form and name-keyed map) are covered, and a
list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
way it does on the top-level door — so `view/layout-without-binding` and
`view/tree-without-parent-field` now reach the nested door by construction rather
than by a second wiring step. Findings report as
`object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
181 changes: 181 additions & 0 deletions packages/lint/src/validate-functional-completeness.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,100 @@ describe('validateFunctionalCompleteness — the walk', () => {
})).toEqual([]);
});

// ── The OBJECT-NESTED door (ADR-0017 "Object has-many View") ────────────
// A container authored on the object itself — `objects[].list` /
// `objects[].listViews.*` — reaches the same renderer as the top-level
// `views[]` copy, so it must reach the same rules. The walk used to stop at
// `stack.views`, which is precisely the half-blind instrument this suite's
// docblock names.

it('walks an object-nested `listViews` view — ARRAY-form objects', () => {
const findings = validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [{ name: 'title', type: 'text' }], listViews: { plan: { type: 'gantt' } } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].severity).toBe('warning');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the same view when objects are name-keyed MAPS', () => {
// The fixture pair the card asks for: both authorable object spellings,
// one rule, one location grammar (the object segment is positional in
// both — the field walk above and `validate-list-view-field-refs` spell
// it the same way).
const findings = validateFunctionalCompleteness({
objects: { task: { fields: { title: { type: 'text' } }, listViews: { plan: { type: 'gantt' } } } },
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('view/layout-without-binding');
expect(findings[0].where).toBe('object "task" › listViews.plan');
expect(findings[0].path).toBe('objects[0].listViews.plan.gantt');
});

it('walks the object-nested default `list` slot too', () => {
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], list: { type: 'timeline' } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/layout-without-binding', 'object "task" › list', 'objects[0].list.timeline'],
]);
});

it('carries `view/tree-without-parent-field` through the nested door as well', () => {
// Not a second wiring step: the tree rule needs the BOUND OBJECT, and the
// nested door resolves it to the container's own object, so every rule the
// predicate has arrives here by construction.
expect(validateFunctionalCompleteness({
objects: [{ name: 'unit', fields: [{ name: 'name', type: 'text' }], listViews: { org: { type: 'tree', tree: {} } } }],
}).map((f) => [f.rule, f.where, f.path])).toEqual([
['view/tree-without-parent-field', 'object "unit" › listViews.org', 'objects[0].listViews.org.tree.parentField'],
]);

// A self-lookup on the object → the renderer auto-detects → silent.
expect(validateFunctionalCompleteness({
objects: { unit: { fields: { parent: { type: 'lookup', reference: 'unit' } }, listViews: { org: { type: 'tree', tree: {} } } } },
})).toEqual([]);

// ADR-0047: the nested view's own `data.object` retargets the lookup, so
// the rule reads `cat`'s fields, not `unit`'s.
expect(validateFunctionalCompleteness({
objects: [
{ name: 'unit', fields: [], listViews: { org: { type: 'tree', tree: {}, data: { provider: 'object', object: 'cat' } } } },
{ name: 'cat', fields: [{ name: 'parent', type: 'tree' }] },
],
})).toEqual([]);
});

it('does not over-report: a nested view WITH its binding is silent', () => {
// The negative half of the pair. A widened walk that fired on complete
// metadata would be worse than the blind one it replaces.
expect(validateFunctionalCompleteness({
objects: [{
name: 'task',
fields: [{ name: 'title', type: 'text' }, { name: 'starts', type: 'datetime' }, { name: 'parent', type: 'lookup', reference: 'task' }],
list: { type: 'grid' },
listViews: {
plan: { type: 'gantt', gantt: { startDateField: 'starts', titleField: 'title' } },
org: { type: 'tree', tree: {} },
flat: { type: 'grid' },
},
}],
})).toEqual([]);
});

it('reports the nested and the top-level copy independently', () => {
// The two doors are separate authorable surfaces, not two readings of one
// definition — an author who wrote both gets a finding for each.
expect(validateFunctionalCompleteness({
objects: [{ name: 'task', fields: [], listViews: { plan: { type: 'gantt' } } }],
views: [{ object: 'task', listViews: { plan: { type: 'gantt' } } }],
}).map((f) => f.path).sort()).toEqual([
'objects[0].listViews.plan.gantt',
'views[0].listViews.plan.gantt',
]);
});

it('walks webhooks in both spellings', () => {
expect(validateFunctionalCompleteness({
webhooks: [{ name: 'notify', url: 'https://x' }],
Expand DownExpand Up@@ -167,6 +261,8 @@ describe('validateFunctionalCompleteness — the walk', () => {
undefined, null, 42, 'x', [], {},
{ objects: 'nope' }, { objects: [null, 7] },
{ objects: [{ name: 'o', fields: 'nope' }] },
{ objects: [{ name: 'o', list: null, listViews: 'nope' }] },
{ objects: { o: { list: 7, listViews: [null, 'x'] } } },
{ views: [{ list: null }] },
{ views: 'nope' },
{ webhooks: 'nope' }, { webhooks: [null, 7] },
Expand DownExpand Up@@ -244,3 +340,88 @@ describe('#14106 acceptance — timeline / map / tree bindings reach `validate`
});
}
});

/**
* The #14320 acceptance criteria, pinned end-to-end through the rule table on
* the card's own repro path (`runAuthoringRules('validate' | 'build', …)`) —
* because the walk being wired is only worth as much as the surface it reaches.
*
* The stack below authors nothing at `stack.views`: every list view lives on
* its object, the ADR-0017 spelling `os validate` used to walk right past.
*/
describe('#14320 acceptance — object-nested `list` / `listViews` reach `validate` AND `build`', () => {
const fields = {
subject: { type: 'text' },
last_update_at: { type: 'datetime' },
site: { type: 'text' },
};
const BINDING_RULES = ['view/layout-without-binding', 'view/tree-without-parent-field'];

/** Array-form objects — the `defineObject` spelling. */
const repro = {
objects: [{
name: 'duly_task',
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
}],
};

/** Map-form objects — the same stack, the other authorable spelling. */
const reproMap = {
objects: {
duly_task: {
fields,
list: { type: 'timeline', columns: ['subject'] },
listViews: {
plan: { type: 'gantt', columns: ['subject'] },
sites: { type: 'map', columns: ['subject'] },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
},
},
},
};

const EXPECTED = [
'view/layout-without-binding @ objects[0].list.timeline',
'view/layout-without-binding @ objects[0].listViews.plan.gantt',
'view/layout-without-binding @ objects[0].listViews.sites.map',
'view/tree-without-parent-field @ objects[0].listViews.flat.tree.parentField',
];

const clean = {
objects: [{
name: 'duly_task',
fields: { ...fields, parent: { type: 'lookup', reference: 'duly_task' } },
list: { type: 'timeline', columns: ['subject'], timeline: { startDateField: 'last_update_at', titleField: 'subject' } },
listViews: {
plan: { type: 'gantt', columns: ['subject'], gantt: { startDateField: 'last_update_at', titleField: 'subject' } },
sites: { type: 'map', columns: ['subject'], map: { locationField: 'site' } },
flat: { type: 'tree', tree: {}, columns: ['subject'] },
declared: { type: 'tree', tree: { parentField: 'parent' }, columns: ['subject'] },
},
}],
};

for (const command of ['validate', 'build'] as const) {
for (const [spelling, stack] of [['array-form', repro], ['map-form', reproMap]] as const) {
it(`the ${spelling} repro is diagnosed by \`${command}\``, () => {
const { advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack as never }));
const hits = advisories
.filter((f) => BINDING_RULES.includes(f.rule))
.map((f) => `${f.rule} @ ${f.path}`)
.sort();
expect(hits).toEqual(EXPECTED);
});
}

it(`the bound object-nested stack passes \`${command}\``, () => {
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: clean as never }));
expect([...errors, ...advisories].filter((f) => BINDING_RULES.includes(f.rule))).toEqual([]);
});
}
});
51 changes: 46 additions & 5 deletions packages/lint/src/validate-functional-completeness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,11 +116,13 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
// is `validate-object-references`' finding.
const objectsByName = new Map(entriesOf(stack.objects).map((o) => [o.name, o.def]));
const strName = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined => {
const own = isRec(view.data) ? strName(view.data.object) : undefined;
const name = own ?? strName(container.objectName) ?? strName(container.object);
return name ? objectsByName.get(name) : undefined;
};
const byName = (name: string | undefined): AnyRec | undefined =>
name ? objectsByName.get(name) : undefined;
/** A list view's own `data.object` retarget (ADR-0047), when it declares one. */
const ownObjectOf = (view: AnyRec): string | undefined =>
(isRec(view.data) ? strName(view.data.object) : undefined);
const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined =>
byName(ownObjectOf(view) ?? strName(container.objectName) ?? strName(container.object));
for (const [vi, container] of entriesOf(stack.views).entries()) {
const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`;
if (isRec(container.def.list)) {
Expand All@@ -141,6 +143,45 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple
}
}

// ── List views: the OBJECT-NESTED container → objects[].list /
// objects[].listViews.* (ADR-0017 "Object has-many View") ─────────────
//
// The same two slots, reached through the other authorable door. An object
// carries its own view container — `lint-view-refs`'s `containerFromObject`
// pulls `list` / `listViews` straight off the object definition, and the
// ADR-0017 loader's `isAggregatedViewContainer` / `expandViewContainer`
// register the expansion — so a `timeline` / `gantt` / `tree` authored here
// reaches exactly the renderer the top-level container's copy reaches. The
// walk above stopped at `stack.views`, which made this gate report coverage
// it did not have: green, and blind to the other half of the stack.
//
// Bound object: the object the container belongs to, unless the view's own
// `data.object` retargets it (ADR-0047) — the resolution
// `validate-list-view-field-refs` uses on this identical rung
// (`listViewObject(lv) ?? objName`). Location grammar is this file's own,
// shared with the field walk directly above: a positional `objects[<i>]`
// (both spellings normalize to one ordered list) and the slot's authored
// key.
for (const [oi, obj] of entriesOf(stack.objects).entries()) {
const where = `object "${obj.name}"`;
if (isRec(obj.def.list)) {
push(
out,
checkViewCompleteness(obj.def.list, byName(ownObjectOf(obj.def.list) ?? obj.name)),
`${where} › list`,
`objects[${oi}].list`,
);
}
for (const lv of entriesOf(obj.def.listViews)) {
push(
out,
checkViewCompleteness(lv.def, byName(ownObjectOf(lv.def) ?? obj.name)),
`${where} › listViews.${lv.name}`,
`objects[${oi}].listViews${lv.key}`,
);
}
}

// ── Webhooks: stack.webhooks[] ─────────────────────────────────────────
// [ADR-0078 Phase 3] The one Tier-B candidate that survived its verification
// pass. A webhook materializes into `sys_webhook` and looks armed in Setup
Expand Down
Loading