Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ const sqlRelation = fieldAttribute('relation', {
positional: [{ key: 'name', type: optional(str()) }],
named: {
name: optional(str()),
fields: optional(list(fieldRef('self'), { nonEmpty: true })),
references: optional(list(fieldRef('referenced'), { nonEmpty: true })),
fields: optional(list(fieldRef(), { nonEmpty: true })),
references: optional(list(referencedFieldRef(), { nonEmpty: true })),
map: optional(str()),
onDelete: optional(
oneOf(
Expand Down Expand Up @@ -46,7 +46,7 @@ The SQL and Mongo family interpreters are the first consumers. They define their

The kit consumes `ExpressionAst` directly. No intermediate argument representation is introduced, and no combinator reparses flattened source text except `json()`, the deliberate quoted-JSON-object exception.

Attributes are a PSL authoring concern, so the kit lives in `psl-parser` rather than framework core. The current constructors cover field and model attributes. `AttributeLevel` reserves a block level, but generic-block attribute construction and interpretation remain future work.
Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. Field, model, and block attributes are all constructed through it. A block descriptor declares which attributes its block accepts, and the generic block reconstruction interprets them at parse time.

---

Expand All @@ -67,44 +67,47 @@ Attributes are a PSL authoring concern, so the kit lives in `psl-parser` rather
An argument combinator parses one `ExpressionAst` into `T`:

```ts
interface ArgType<T> {
interface ArgType<T, Ctx extends AttributeCtx> {
readonly kind: string;
readonly label: string;
readonly _out?: T;
parse(arg: ExpressionAst, ctx: InterpretCtx): Result<T, readonly PslDiagnostic[]>;
readonly parse: (arg: ExpressionAst, ctx: Ctx) => Result<T, readonly PslDiagnostic[]>;
}
```

The context contains the source and family symbols needed by the shipped reference combinators:
A combinator declares what it reads. The contexts nest by what the site being parsed actually has, so a spec cannot demand facts its level never carries.

```ts
interface InterpretCtx {
readonly level: 'field' | 'model' | 'block';
interface AttributeCtx {
readonly sourceId: string;
readonly sourceFile: SourceFile;
}

interface ModelAttributeCtx extends AttributeCtx {
readonly selfModel: ModelSymbol;
}

interface FieldAttributeCtx extends ModelAttributeCtx {
readonly field: FieldSymbol;
resolveReferencedModel(): ModelSymbol | undefined;
readonly field?: FieldSymbol;
}
```

A block has no model, so a block attribute is parsed with only the source context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist.

A spec fixes the attribute level and name, declares its arguments, and may refine the parsed result:

```ts
interface AttributeSpec<Out> {
interface AttributeSpec<Out, Ctx extends AttributeCtx> {
readonly level: 'field' | 'model' | 'block';
readonly name: string;
readonly positional: readonly PositionalParam[];
readonly named: Readonly<Record<string, Param<unknown>>>;
readonly refine?: (
parsed: Out,
ctx: InterpretCtx,
attributeNode: AstNode,
) => readonly PslDiagnostic[];
readonly positional: readonly PositionalParam<unknown, Ctx>[];
readonly named: Readonly<Record<string, Param<unknown, Ctx>>>;
readonly refine?: (parsed: Out, ctx: Ctx, attributeNode: AstNode) => readonly PslDiagnostic[];
}
```

`fieldAttribute` and `modelAttribute` infer `AttributeOut<Pos, Named>` when constructing a spec. `InferAttr<S>` extracts that `Out` type. Optional parameters are `ArgType` values decorated by `optional(type)` or `optional(type, defaultValue)`; the engine detects the marker when finalizing absent arguments.
Each constructor fixes the context its level carries and infers `AttributeOut<Pos, Named>` when constructing a spec. `InferAttr<S>` extracts that `Out` type. Optional parameters are `ArgType` values decorated by `optional(type)` or `optional(type, defaultValue)`; the engine detects the marker when finalizing absent arguments.

Positionals are fixed slots with an output key. Variadic positionals are not supported. Positional and named parameters may intentionally share a key, which supports the relation-name alias while allowing the engine to diagnose conflicting duplicate values.

Expand Down Expand Up @@ -137,7 +140,7 @@ These leaves perform direct AST checks. They do not wrap arktype schemas.

### References

`fieldRef('self')` parses a field-name identifier and validates it against the declaring model. `fieldRef('referenced')` validates against the relation target when that model can be resolved; cross-space references may defer the existence check when no referenced model is locally available. Both forms return the authored field name as a string and expose their scope as combinator metadata.
`fieldRef()` parses a field-name identifier and validates it against the declaring model, so it is available to model and field attributes alike. `referencedFieldRef()` validates against the relation target, which only a field can resolve; cross-space references may defer the existence check when no referenced model is locally available. Both return the authored field name as a string.

`entityRef()` parses an unresolved model-name string. Existence and family semantics remain downstream concerns.

Expand Down Expand Up @@ -173,8 +176,8 @@ This trade-off keeps the leaf contract small and allows backtracking, at the cos

```ts
interface FuncCallSig {
readonly positional?: readonly PositionalParam<unknown>[];
readonly named?: Readonly<Record<string, Param<unknown>>>;
readonly positional?: readonly PositionalParam<unknown, AttributeCtx>[];
readonly named?: Readonly<Record<string, Param<unknown, AttributeCtx>>>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

interface TypedFuncCall {
Expand Down Expand Up @@ -219,7 +222,7 @@ const sortSig = {
} satisfies FuncCallSig;

const indexFieldElement = oneOf(
fieldRef('self'),
fieldRef(),
funcCall('wildcard', {
positional: [{ key: 'scope', type: optional(entityRef()) }],
}),
Expand Down Expand Up @@ -298,7 +301,6 @@ The current implementation is sufficient for interpreter consumption but not yet

- Add central spec discovery and traversable combinator metadata for language-tooling consumers.
- Decide whether reference combinators should expose declaration-bearing results while preserving the interpreter's string-oriented lowering needs.
- Add block-level construction and interpretation if generic-block attributes adopt this mechanism.
- Revisit signature-derived `TypedFuncCall` output types if downstream code needs statically discriminated call unions.
- Decide whether literal-to-field-type compatibility should remain in lowering or gain a dedicated field-context combinator.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type {
PslExtensionBlockParamRef,
PslExtensionBlockParamScalarValue,
PslExtensionBlockParamValue,
PslExtensionBlockParsedAttribute,
Comment thread
SevInf marked this conversation as resolved.
PslPosition,
PslSpan,
} from '../shared/psl-extension-block';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,5 @@ export type {
PslExtensionBlockParamRef,
PslExtensionBlockParamScalarValue,
PslExtensionBlockParamValue,
PslExtensionBlockParsedAttribute,
} from '../shared/psl-extension-block';
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isColumnDefaultLiteralInputValue,
isExecutionMutationDefaultValue,
} from '@internal/contract/types';
import { invariant } from '@internal/utils/assertions';
import { blindCast } from '@internal/utils/casts';
import { ifDefined } from '@internal/utils/defined';
import { InternalError } from '@internal/utils/internal-error';
Expand Down Expand Up @@ -330,7 +331,7 @@ export function resolveEnumCodecId(
ctx: AuthoringEntityContext,
): { readonly codecId: string; readonly codecSpan: PslSpan } | undefined {
const sourceId = ctx.sourceId ?? 'unknown';
const typeAttr = block.blockAttributes.find((a) => a.name === 'type');
const typeAttr = block.attributes['type'];

if (typeAttr === undefined) {
const inferredKind = classifyEnumMemberType(block);
Expand All @@ -346,21 +347,9 @@ export function resolveEnumCodecId(
return { codecId: ctx.enumInferenceCodecs[inferredKind], codecSpan: block.span };
}

const rawCodecArg = typeAttr.args[0]?.value;
const codecId =
rawCodecArg?.startsWith('"') && rawCodecArg.endsWith('"') && rawCodecArg.length >= 2
? rawCodecArg.slice(1, -1)
: undefined;
if (codecId === undefined) {
ctx.diagnostics?.push({
code: 'PSL_ENUM_MISSING_TYPE',
message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`,
sourceId,
span: typeAttr.span,
});
return undefined;
}
return { codecId, codecSpan: typeAttr.args[0]?.span ?? typeAttr.span };
const codecId = typeAttr.args['codecId'];
invariant(typeof codecId === 'string', '@@type on an enum block parses one string argument');
return { codecId, codecSpan: typeAttr.span };
Comment thread
SevInf marked this conversation as resolved.
}

export interface AuthoringEntityTypeTemplateOutput {
Expand Down Expand Up @@ -464,6 +453,7 @@ export interface AuthoringPslBlockDescriptor {
readonly parameter: string;
readonly attribute: string;
};
readonly attributes?: Readonly<Record<string, unknown>>;
}

export type AuthoringPslBlockDescriptorNamespace = {
Expand Down Expand Up @@ -735,7 +725,15 @@ function isWellFormedDescriptor(value: unknown, descriptorKind: string): boolean
if (!('required' in name) || typeof name.required !== 'boolean') return false;
if (!('parameters' in value)) return false;
const parameters = value.parameters;
return typeof parameters === 'object' && parameters !== null && !Array.isArray(parameters);
if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) {
return false;
}
if (!('attributes' in value) || value.attributes === undefined) return true;
const attributes = value.attributes;
if (typeof attributes !== 'object' || attributes === null || Array.isArray(attributes)) {
return false;
}
return Object.values(attributes).every((factory) => typeof factory === 'function');
}
case 'modelAttribute': {
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export type PslDiagnosticCode =
* A `@@`-prefixed block-attribute line inside an extension block has invalid syntax.
*/
| 'PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE'
| 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE'
/**
* Duplicate scopes are top level, namespace body, or block fields; diagnostics
* are first-wins and anchored on later name spans.
Expand Down Expand Up @@ -251,6 +252,11 @@ export interface PslExtensionBlockAttribute {
readonly span: PslSpan;
}

export interface PslExtensionBlockParsedAttribute {
readonly args: Readonly<Record<string, unknown>>;
readonly span: PslSpan;
}

/**
* Base shape for a uniform extension-contributed top-level PSL block
* node, as produced by the generic framework parser and consumed by the
Expand Down Expand Up @@ -294,5 +300,6 @@ export interface PslExtensionBlock {
readonly name: string;
readonly parameters: Record<string, PslExtensionBlockParamValue>;
readonly blockAttributes: readonly PslExtensionBlockAttribute[];
readonly attributes: Readonly<Record<string, PslExtensionBlockParsedAttribute>>;
readonly span: PslSpan;
}
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,71 @@ describe('assembleAuthoringContributions', () => {
).toThrow(/Malformed authoring pslBlock contribution at "broken"/);
});

it('keeps a pslBlockDescriptors entry that declares block attributes', () => {
const mapFactory = () => ({ level: 'block', name: 'map' });
const result = assembleAuthoringContributions([
createDescriptor({
authoring: {
entityTypes: {
foo: { kind: 'entity', discriminator: 'fake-foo', output: { factory: () => ({}) } },
},
pslBlockDescriptors: {
fooBlock: {
...makeDeclarativePslBlockDescriptor('fake-foo'),
attributes: { map: mapFactory },
},
},
},
}),
]);
expect(result.pslBlockDescriptors['fooBlock']).toMatchObject({
attributes: { map: mapFactory },
});
});

it.each([
['an undefined factory', { map: undefined }],
['a non-function factory', { map: 'map' }],
])('rejects a pslBlockDescriptors entry whose attributes carries %s', (_label, attributes) => {
expect(() =>
assembleAuthoringContributions([
createDescriptor({
authoring: {
entityTypes: {
foo: { kind: 'entity', discriminator: 'fake-foo', output: { factory: () => ({}) } },
},
pslBlockDescriptors: {
fooBlock: {
...makeDeclarativePslBlockDescriptor('fake-foo'),
attributes,
} as unknown as never,
},
},
}),
]),
).toThrow(/Malformed authoring pslBlock contribution at "fooBlock"/);
});

it('rejects a pslBlockDescriptors entry whose attributes is not a record', () => {
expect(() =>
assembleAuthoringContributions([
createDescriptor({
authoring: {
entityTypes: {
foo: { kind: 'entity', discriminator: 'fake-foo', output: { factory: () => ({}) } },
},
pslBlockDescriptors: {
fooBlock: {
...makeDeclarativePslBlockDescriptor('fake-foo'),
attributes: 'map',
} as unknown as never,
},
},
}),
]),
).toThrow(/Malformed authoring pslBlock contribution at "fooBlock"/);
});

it('descends into a pslBlockDescriptors sub-namespace whose key is "kind" or "discriminator" without triggering malformed check', () => {
// A sub-namespace keyed "kind" or "discriminator" that does not itself
// look like a descriptor must descend normally.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,7 @@ describe('classifyEnumMemberType', () => {
name: 'TestEnum',
parameters,
blockAttributes: [],
attributes: {},
span: testSpan,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ function makeExtensionBlock(
name: string,
keyword: string = discriminator,
): PslExtensionBlock {
return { kind: discriminator, keyword, name, parameters: {}, blockAttributes: [], span: SPAN };
return {
kind: discriminator,
keyword,
name,
parameters: {},
blockAttributes: [],
attributes: {},
span: SPAN,
};
}

describe('makePslNamespace / makePslNamespaceEntries', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type {
PslBlockParamOption,
PslBlockParamRef,
PslBlockParamValue,
PslExtensionBlock,
PslExtensionBlockParsedAttribute,
} from '../src/shared/psl-extension-block';

describe('PslBlockParam discriminated union', () => {
Expand Down Expand Up @@ -157,3 +159,30 @@ describe('isAuthoringPslBlockDescriptor', () => {
}
});
});

describe('block attributes', () => {
it('a descriptor declares its block attributes as erased factories, sibling of parameters', () => {
const descriptor = {
kind: 'pslBlock',
keyword: 'native_enum',
discriminator: 'native_enum',
name: { required: true },
parameters: {},
attributes: { map: () => ({ level: 'block', name: 'map' }) },
} as const;
expectTypeOf(descriptor).toMatchTypeOf<AuthoringPslBlockDescriptor>();
expectTypeOf<AuthoringPslBlockDescriptor['attributes']>().toEqualTypeOf<
Readonly<Record<string, unknown>> | undefined
>();
});

it('a block node carries its parsed attributes as plain data keyed by attribute name', () => {
expectTypeOf<PslExtensionBlock['attributes']>().toEqualTypeOf<
Readonly<Record<string, PslExtensionBlockParsedAttribute>>
>();
expectTypeOf<PslExtensionBlockParsedAttribute['args']>().toEqualTypeOf<
Readonly<Record<string, unknown>>
>();
expectTypeOf<Omit<PslExtensionBlock, 'attributes'>>().not.toMatchTypeOf<PslExtensionBlock>();
});
});
Loading
Loading