mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-10 08:27:27 +00:00
* docs: align resolution architecture and roadmap * docs: add language ingestion gap inventories * test: audit receiver gap targets and coverage Check exact declaration IDs, expose the Dart await wrong-owner edge, and execute previously excluded C++, Rust, PHP, Dart and Vue shapes. Refresh the baseline and all language inventories from fresh measurements; call-drop counts remain unchanged. * docs: focus roadmap and shorten project README Keep the resolution roadmap limited to future work and acceptance criteria. Move detailed editor, CLI, MCP, deployment and wiki instructions into linked guides, preserve their command examples, and correct stale onboarding claims. * docs: correct setup and audit descriptions --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2142 lines
69 KiB
JavaScript
2142 lines
69 KiB
JavaScript
/**
|
|
* Receiver-resolution measurement harness.
|
|
*
|
|
* Answers one question — "how many method calls does GitNexus lose because it
|
|
* could not establish the receiver's type, and which source shapes are they?" —
|
|
* and answers it in a way that can gate a decision.
|
|
*
|
|
* TWO ARMS, because neither one alone is trustworthy:
|
|
*
|
|
* 1. SHAPE ARM (`--shapes`, default). A fixed matrix of receiver spellings —
|
|
* every SHAPE_ID against every language — each cell classified by what the
|
|
* graph actually contains:
|
|
*
|
|
* RESOLVES the call reaches its declared expectedTarget node ID,
|
|
* with no same-named call to another declaration.
|
|
* WRONG-TARGET at least one same-named edge reaches a different owner,
|
|
* including an edge from an intentionally untyped control.
|
|
* VISIBLE-GAP no edge, and a `receiver-unresolved` drop was recorded.
|
|
* Measurable by the count arm below.
|
|
* INVISIBLE-GAP no edge, and NO drop was recorded. The call is lost and
|
|
* the instrument cannot see it.
|
|
* N/A the language's grammar does not admit this spelling,
|
|
* or the spelling has no static callee an edge could
|
|
* point at (a C call through a function-pointer field).
|
|
* Carries a REQUIRED reason — an omitted cell and a
|
|
* genuinely inapplicable one look identical in a diff
|
|
* otherwise, and that is how coverage silently rots.
|
|
* GRAMMAR- the language's parser could not be loaded, so nothing
|
|
* UNAVAILABLE about it was measured. Neither passes nor fails the
|
|
* gate. Dart, Kotlin and Swift are vendored OPTIONAL
|
|
* grammars: they are skipped entirely by
|
|
* GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1, and they soft-fail
|
|
* when no vendored prebuild matches the host (the set
|
|
* covers darwin/linux arm64+x64 and win32-arm64 — a
|
|
* win32-x64 or musl host has none). Without this state an
|
|
* unloaded grammar emits no edges and every one of its
|
|
* cells reads as a resolution gap indistinguishable from a
|
|
* real regression.
|
|
* NOTE: all 14 load on a glibc linux-x64 host, so this
|
|
* state has no producer in the committed baseline. It
|
|
* guards the skip-flag and unsupported-host cases.
|
|
*
|
|
* INVISIBLE-GAP is why this arm exists, and the series proved it: at the
|
|
* time this arm was built `svc?.getUser().save()`, `svc.getTyped<User>().save()`
|
|
* and `repos[0].save()` were all INVISIBLE, so fixing them moved the count
|
|
* arm by exactly zero and a gate reading only the drop count would have
|
|
* scored a working change as "no improvement". They resolve for TypeScript
|
|
* now; the current per-shape state is `baseline.json`, never this comment.
|
|
*
|
|
* COMPLETENESS IS ENFORCED: every language must declare a cell for every
|
|
* SHAPE_ID, and every N/A must carry a reason. A missing cell fails the run
|
|
* rather than being silently skipped.
|
|
*
|
|
* 2. COUNT ARM (`--corpus <repoPath>`). Runs the real pipeline over a repo and
|
|
* reports drops SPLIT BY SITE KIND. The gate number is `call` only: the
|
|
* recorder's gate tests the receiver's punctuation, not the site's kind, so
|
|
* property reads (`d.source.kind`) and writes (`x.argtypes = [...]`) land in
|
|
* the same bucket as lost method calls and would inflate it.
|
|
*
|
|
* KNOWN BLIND SPOTS — reported in every run, deliberately, because the number
|
|
* is a lower bound on a KNOWN-BIASED population and any delta measured later
|
|
* must be read against the same bias:
|
|
*
|
|
* - Case 0 is reached by a receiver-TEXT punctuation test (`.` or `(`) OR by a
|
|
* minted receiver chain, so a receiver spelled without that punctuation — a
|
|
* subscript, PHP `->`/`::` — records a drop only where its emitter mints a
|
|
* chain. Where none is minted the call still vanishes unrecorded.
|
|
* - A drop is recorded only while `compoundReceiverUnresolved` stays true: if
|
|
* the cascade types the receiver but finds no member, no drop is recorded
|
|
* even though no edge was emitted, so an absent drop is not proof of
|
|
* resolution. (Superseded claim, kept because it was quoted for several
|
|
* units: `?.` and explicit type arguments DO produce a reference site —
|
|
* what was absent was the edge and the drop, never the site.)
|
|
*
|
|
* MEASUREMENT HYGIENE — a run that skips this is void:
|
|
*
|
|
* npm run build # the parse worker runs from dist/
|
|
* rm -rf .gitnexus/parse-cache .gitnexus/parsedfile-cache
|
|
* node --import tsx bench/receiver-resolution/measure.mjs --corpus <repo>
|
|
*
|
|
* `analyze --force` clears NEITHER cache, so a stale shard will happily serve
|
|
* the previous capture set and produce a confident, wrong number.
|
|
*
|
|
* Usage:
|
|
* node --import tsx bench/receiver-resolution/measure.mjs
|
|
* node --import tsx bench/receiver-resolution/measure.mjs --corpus /path/to/repo
|
|
*/
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.ts';
|
|
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/index.ts';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const BASELINE_PATH = path.resolve(__dirname, 'baseline.json');
|
|
/** The `--check` corpus. Committed and multi-language, so the gate is
|
|
* deterministic and does not depend on anything outside the repo. */
|
|
const DEFAULT_CORPUS = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shape corpus
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The canonical shape axis. Every language corpus must declare a cell for every
|
|
* id here — a body to measure, or `na` with a reason. `assertMatrixComplete`
|
|
* enforces it, so adding a shape forces every language to answer for it.
|
|
*/
|
|
const SHAPE_IDS = [
|
|
'plainChain',
|
|
'plainDeepChain',
|
|
'optionalChain',
|
|
'nonNullAssert',
|
|
'awaitParen',
|
|
'explicitTypeArgs',
|
|
'indexElement',
|
|
'fieldReceiverCall',
|
|
'decoratedReceiverBase',
|
|
'decoratedFieldType',
|
|
];
|
|
|
|
/**
|
|
* Providers outside this method-receiver measurement domain. Their separate
|
|
* call-resolution integration tests remain necessary; N/A is not a passing
|
|
* runtime measurement and does not establish whole-language completeness.
|
|
*/
|
|
const NA_LANGUAGES = {
|
|
cobol:
|
|
'The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage.',
|
|
};
|
|
|
|
/**
|
|
* Each entry is one receiver spelling. `entry` is the function that contains
|
|
* it; `member` selects the terminal call and `expectedTarget` names its full
|
|
* declaration ID (null for an intentional unresolved control). Classification
|
|
* checks every matching edge against that ID, and only a call-kind drop in the
|
|
* generated main file at that shape line can make a missing edge visible.
|
|
*
|
|
* An entry with `na` declares the shape inapplicable to this language and is
|
|
* never compiled into the fixture.
|
|
*/
|
|
const CORPORA = [
|
|
{
|
|
lang: 'typescript',
|
|
ext: '.ts',
|
|
// `fourHopChain` exists to make MAX_CHAIN_DEPTH answerable. Without a chain
|
|
// longer than the cap, raising the cap measures nothing and the question
|
|
// "what does depth N buy?" has no instrument behind it.
|
|
extraShapeIds: ['fourHopChain'],
|
|
support: {
|
|
'models.ts': `export class City {
|
|
save(): void {}
|
|
}
|
|
|
|
export class Address {
|
|
save(): void {}
|
|
getCity(): City {
|
|
return new City();
|
|
}
|
|
}
|
|
|
|
export class User {
|
|
name: string = '';
|
|
address: Address = new Address();
|
|
save(): void {}
|
|
}
|
|
|
|
export class Service {
|
|
getUser(): User {
|
|
return new User();
|
|
}
|
|
async getUserAsync(): Promise<User> {
|
|
return new User();
|
|
}
|
|
getTyped<T>(): User {
|
|
return new User();
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
header: `import { Service, User } from './models';\n`,
|
|
wrap: (entry, body) =>
|
|
`export async function ${entry}(svc: Service, repos: User[]): Promise<void> {\n ${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:models.ts:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().save();',
|
|
note: 'control',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:models.ts:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().address.save();',
|
|
note: 'control',
|
|
},
|
|
{
|
|
id: 'optionalChain',
|
|
expectedTarget: 'Method:models.ts:User.save#0',
|
|
member: 'save',
|
|
body: 'svc?.getUser().save();',
|
|
note: 'PF1',
|
|
},
|
|
{
|
|
id: 'nonNullAssert',
|
|
expectedTarget: 'Method:models.ts:User.save#0',
|
|
member: 'save',
|
|
body: 'svc!.getUser().save();',
|
|
note: 'PF2',
|
|
},
|
|
{
|
|
id: 'awaitParen',
|
|
expectedTarget: 'Method:models.ts:User.save#0',
|
|
member: 'save',
|
|
body: '(await svc.getUserAsync()).save();',
|
|
note: 'PF3',
|
|
},
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
expectedTarget: 'Method:models.ts:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.getTyped<User>().save();',
|
|
note: 'PF4',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:models.ts:User.save#0',
|
|
member: 'save',
|
|
body: 'repos[0].save();',
|
|
note: 'PF5',
|
|
},
|
|
{
|
|
// FOUR STEPS in the receiver of `save`: getSvc, getUser, address,
|
|
// getCity. One more than the pre-U6 cap of 3, so the chain is DISCARDED
|
|
// WHOLE at depth 3 and types end to end at depth 4 — the whole basis
|
|
// for choosing the number. (An earlier version of this fixture had only
|
|
// three steps and therefore resolved at both depths, measuring nothing.)
|
|
id: 'fourHopChain',
|
|
expectedTarget: 'Method:models.ts:City.save#0',
|
|
member: 'save',
|
|
body: 'root.getSvc().getUser().address.getCity().save();',
|
|
raw: `class Root {
|
|
getSvc(): Service {
|
|
return new Service();
|
|
}
|
|
}
|
|
|
|
export function fourHopChain(root: Root): void {
|
|
root.getSvc().getUser().address.getCity().save();
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:models.ts:User.save#0',
|
|
member: 'save',
|
|
body: 'this.repo.save();',
|
|
raw: `class TsHolder {
|
|
repo: User = new User();
|
|
fieldReceiverCall(): void {
|
|
this.repo.save();
|
|
}
|
|
}
|
|
`,
|
|
note: 'control — undecorated field receiver',
|
|
},
|
|
{
|
|
id: 'decoratedReceiverBase',
|
|
na: 'TypeScript `this` carries no type decoration — there is no pointer/reference spelling on a method receiver.',
|
|
},
|
|
{
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Method:models.ts:User.save#0',
|
|
member: 'save',
|
|
body: 'this.repo!.save();',
|
|
raw: `class TsNullableHolder {
|
|
repo: User | null = null;
|
|
decoratedFieldType(): void {
|
|
this.repo!.save();
|
|
}
|
|
}
|
|
`,
|
|
note: 'union-with-null field type',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'php',
|
|
ext: '.php',
|
|
// `arrowCallChain` / `arrowPropertyPath` discriminate an unannotated return
|
|
// type from a property-path receiver — neither is expressible on the
|
|
// canonical axis, and both are load-bearing for the PHP diagnosis.
|
|
// `arrowCallChain` is also the row's ONE untyped control: every canonical
|
|
// chain cell goes through the annotated `getUserTyped()`, so each measures
|
|
// its own spelling rather than the missing return type.
|
|
extraShapeIds: ['arrowCallChain', 'arrowPropertyPath'],
|
|
support: {
|
|
'models.php': `<?php
|
|
class Address {
|
|
public function save() {}
|
|
}
|
|
|
|
class User {
|
|
public Address $address;
|
|
public function save() {}
|
|
}
|
|
|
|
class Service {
|
|
// Deliberately UNANNOTATED. Only \`arrowCallChain\` measures this one: it
|
|
// is the row's untyped control, and annotating it here would erase the
|
|
// signal instead of explaining it.
|
|
public function getUser() {
|
|
return new User();
|
|
}
|
|
|
|
// The annotated twin. \`plainChain\`, \`plainDeepChain\` and
|
|
// \`optionalChain\` all go through this one, so each cell measures its own
|
|
// spelling. \`plainChain\` against \`arrowCallChain\` discriminates "PHP
|
|
// cannot chain" from "PHP cannot infer an unannotated return type".
|
|
public function getUserTyped(): User {
|
|
return new User();
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
header: `<?php\nrequire_once 'models.php';\n`,
|
|
// TYPED parameter. An untyped `$svc` has no type binding to resolve the
|
|
// chain's base against, which would make this row report a language gap
|
|
// that is really a fixture defect. `$repos` is a native `array`, which says
|
|
// nothing about its elements, so `indexElement` adds the PHPDoc
|
|
// `@param User[] $repos` that PHP code uses to type an array's elements.
|
|
wrap: (entry, body) => `function ${entry}(Service $svc, array $repos) {\n ${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'arrowCallChain',
|
|
expectedTarget: null,
|
|
member: 'save',
|
|
body: '$svc->getUser()->save();',
|
|
note: 'PF6 — recorded, because the receiver text contains `(`',
|
|
},
|
|
{
|
|
// The discriminating control for KTD6 defect 2. This receiver
|
|
// (`$this->repo`) contains neither `.` nor `(`, so Case 0's gate never
|
|
// fires and the drop is never recorded — while the call chain above IS
|
|
// recorded. "PHP records no drops" is too coarse: it is the
|
|
// property-path receiver that is invisible, not the language.
|
|
id: 'arrowPropertyPath',
|
|
expectedTarget: 'Method:models.php:User.save#0',
|
|
member: 'save',
|
|
body: '$this->repo->save();',
|
|
raw: `class Holder {
|
|
public User $repo;
|
|
public function arrowPropertyPath() {
|
|
$this->repo->save();
|
|
}
|
|
}
|
|
`,
|
|
note: 'PF6-control — property-path receiver, no `.` and no `(`',
|
|
},
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:models.php:User.save#0',
|
|
member: 'save',
|
|
body: '$svc->getUserTyped()->save();',
|
|
note: 'discriminator for arrowCallChain — same chain, ANNOTATED return type',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:models.php:Address.save#0',
|
|
member: 'save',
|
|
body: '$svc->getUserTyped()->address->save();',
|
|
note: 'deep arrow chain',
|
|
},
|
|
{
|
|
id: 'optionalChain',
|
|
expectedTarget: 'Method:models.php:User.save#0',
|
|
member: 'save',
|
|
body: '$svc?->getUserTyped()->save();',
|
|
note: 'nullsafe',
|
|
},
|
|
{
|
|
id: 'nonNullAssert',
|
|
na: 'PHP has no non-null assertion operator.',
|
|
},
|
|
{ id: 'awaitParen', na: 'PHP has no await expression in the core language.' },
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
na: 'PHP has no generic type-argument syntax at a call site.',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:models.php:User.save#0',
|
|
member: 'save',
|
|
body: '$repos[0]->save();',
|
|
raw: `/**
|
|
* @param User[] $repos
|
|
*/
|
|
function indexElement(Service $svc, array $repos) {
|
|
$repos[0]->save();
|
|
}
|
|
`,
|
|
note: 'array subscript, element type from PHPDoc',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:models.php:User.save#0',
|
|
member: 'save',
|
|
body: '$this->repo->save();',
|
|
raw: `class PhpHolder {
|
|
public User $repo;
|
|
public function fieldReceiverCall() {
|
|
$this->repo->save();
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
id: 'decoratedReceiverBase',
|
|
na: 'PHP `$this` carries no type decoration on a method receiver.',
|
|
},
|
|
{
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Method:models.php:User.save#0',
|
|
member: 'save',
|
|
body: '$this->repo->save();',
|
|
raw: `class NullableHolder {
|
|
public ?User $repo;
|
|
public function decoratedFieldType() {
|
|
$this->repo->save();
|
|
}
|
|
}
|
|
`,
|
|
note: 'nullable field type',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'cpp',
|
|
ext: '.cpp',
|
|
// `pointerArrowChain` / `valueDotChain` discriminate a `->` base from a `.`
|
|
// base on the same chain — the pair is why PF7 was attributable to the base
|
|
// rather than to C++ chaining in general.
|
|
extraShapeIds: ['pointerArrowChain', 'valueDotChain'],
|
|
support: {
|
|
'models.h': `#pragma once
|
|
|
|
class Address {
|
|
public:
|
|
void save();
|
|
};
|
|
|
|
class User {
|
|
public:
|
|
Address addr;
|
|
void save();
|
|
};
|
|
|
|
class Service {
|
|
public:
|
|
User* getUser();
|
|
template <typename T>
|
|
T* getTyped();
|
|
};
|
|
|
|
#include <coroutine>
|
|
struct AwaitUser {
|
|
bool await_ready();
|
|
void await_suspend(std::coroutine_handle<>);
|
|
User await_resume();
|
|
};
|
|
struct Task {
|
|
struct promise_type {
|
|
Task get_return_object();
|
|
std::suspend_never initial_suspend();
|
|
std::suspend_never final_suspend() noexcept;
|
|
void return_void();
|
|
void unhandled_exception();
|
|
};
|
|
};
|
|
AwaitUser getUserAsync();
|
|
`,
|
|
},
|
|
header: `#include "models.h"\n`,
|
|
wrap: (entry, body) =>
|
|
`void ${entry}(Service* svc, Service svc2, User* repos) {\n ${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'pointerArrowChain',
|
|
expectedTarget: 'Method:models.h:User.save#0',
|
|
member: 'save',
|
|
body: 'svc->getUser()->save();',
|
|
note: 'pointer base; measured separately from the value-base control',
|
|
},
|
|
{
|
|
// The discriminating control for PF7. Same chain, same `->save()` tail,
|
|
// but a value `.` on the base receiver. Both now resolve; retaining
|
|
// both forms prevents a regression in either spelling from hiding.
|
|
id: 'valueDotChain',
|
|
expectedTarget: 'Method:models.h:User.save#0',
|
|
member: 'save',
|
|
body: 'svc2.getUser()->save();',
|
|
note: 'PF7-control — value `.` base resolves',
|
|
},
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:models.h:User.save#0',
|
|
member: 'save',
|
|
body: 'svc->getUser()->save();',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:models.h:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc->getUser()->addr.save();',
|
|
},
|
|
{ id: 'optionalChain', na: 'C++ has no optional-chaining operator.' },
|
|
{ id: 'nonNullAssert', na: 'C++ has no non-null assertion operator.' },
|
|
{
|
|
id: 'awaitParen',
|
|
expectedTarget: 'Method:models.h:User.save#0',
|
|
member: 'save',
|
|
body: '(co_await getUserAsync()).save();',
|
|
raw: `Task awaitParen() {
|
|
(co_await getUserAsync()).save();
|
|
co_return;
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
expectedTarget: 'Method:models.h:User.save#0',
|
|
member: 'save',
|
|
body: 'svc->getTyped<User>()->save();',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:models.h:User.save#0',
|
|
member: 'save',
|
|
body: 'repos[0].save();',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:models.h:User.save#0',
|
|
member: 'save',
|
|
body: 'this->repo.save();',
|
|
raw: `struct CppHolder {
|
|
User repo;
|
|
void fieldReceiverCall() {
|
|
this->repo.save();
|
|
}
|
|
};
|
|
`,
|
|
note: 'control — undecorated field receiver',
|
|
},
|
|
{
|
|
id: 'decoratedReceiverBase',
|
|
na: 'C++ `this` is implicitly a pointer with no written decoration; the pointer base is measured by pointerArrowChain.',
|
|
},
|
|
{
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Method:models.h:User.save#0',
|
|
member: 'save',
|
|
body: 'this->repo->save();',
|
|
raw: `struct CppPtrHolder {
|
|
User* repo;
|
|
void decoratedFieldType() {
|
|
this->repo->save();
|
|
}
|
|
};
|
|
`,
|
|
note: 'pointer field type',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
// Go is the language whose root cause this plan proves: a method with a
|
|
// POINTER receiver binds its receiver to the literal string `*Host`, which
|
|
// findClassBindingInScope cannot resolve. The three field-receiver rows
|
|
// below isolate that — they vary receiver decoration and field decoration
|
|
// independently, so a gap can be attributed to one or the other rather than
|
|
// to "Go field receivers" as a whole.
|
|
lang: 'go',
|
|
ext: '.go',
|
|
support: {
|
|
'models.go': `package main
|
|
|
|
type Address struct{}
|
|
|
|
func (a *Address) Save() {}
|
|
|
|
type User struct {
|
|
Address *Address
|
|
}
|
|
|
|
func (u *User) Save() {}
|
|
|
|
type Service struct{}
|
|
|
|
func (s *Service) GetUser() *User { return &User{} }
|
|
|
|
type Tgt struct{}
|
|
|
|
func (t *Tgt) PtrM() {}
|
|
|
|
func (t Tgt) ValM() {}
|
|
`,
|
|
},
|
|
header: `package main\n`,
|
|
wrap: (entry, body) => `func ${entry}(svc *Service, repos []*User) {\n\t${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:models.go:User.Save#0',
|
|
member: 'Save',
|
|
body: 'svc.GetUser().Save()',
|
|
note: 'control',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:models.go:Address.Save#0',
|
|
member: 'Save',
|
|
body: 'svc.GetUser().Address.Save()',
|
|
note: 'control',
|
|
},
|
|
{ id: 'optionalChain', na: 'Go has no optional-chaining operator.' },
|
|
{ id: 'nonNullAssert', na: 'Go has no non-null assertion operator.' },
|
|
{ id: 'awaitParen', na: 'Go has no await expression; concurrency is channel-based.' },
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
na: 'Go methods cannot declare type parameters, so there is no call-site type-argument spelling on a method.',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:models.go:User.Save#0',
|
|
member: 'Save',
|
|
body: 'repos[0].Save()',
|
|
note: 'slice subscript',
|
|
},
|
|
{
|
|
// VALUE receiver + VALUE field. Neither side carries decoration, so this
|
|
// is the control that isolates the two rows below.
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:models.go:Tgt.ValM#0',
|
|
member: 'ValM',
|
|
body: 'h.f.ValM()',
|
|
raw: `type HostVV struct{ f Tgt }
|
|
|
|
func (h HostVV) fieldReceiverCall() {
|
|
h.f.ValM()
|
|
}
|
|
`,
|
|
note: 'control — value receiver, value field',
|
|
},
|
|
{
|
|
// POINTER receiver + VALUE field. The receiver binds as `*HostPV` and
|
|
// the class lookup misses every branch (no dot, so the dotted-tail
|
|
// fallback never fires). THE root cause this plan fixes.
|
|
id: 'decoratedReceiverBase',
|
|
expectedTarget: 'Method:models.go:Tgt.ValM#0',
|
|
member: 'ValM',
|
|
body: 'h.f.ValM()',
|
|
raw: `type HostPV struct{ f Tgt }
|
|
|
|
func (h *HostPV) decoratedReceiverBase() {
|
|
h.f.ValM()
|
|
}
|
|
`,
|
|
note: 'pointer receiver, value field — isolates the BASE',
|
|
},
|
|
{
|
|
// VALUE receiver + POINTER field. Go already normalizes field type
|
|
// bindings through normalizeGoTypeName, so this resolves today and
|
|
// proves the step lookup is NOT the defect.
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Method:models.go:Tgt.PtrM#0',
|
|
member: 'PtrM',
|
|
body: 'h.f.PtrM()',
|
|
raw: `type HostVP struct{ f *Tgt }
|
|
|
|
func (h HostVP) decoratedFieldType() {
|
|
h.f.PtrM()
|
|
}
|
|
`,
|
|
note: 'value receiver, pointer field — isolates the STEP',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'javascript',
|
|
ext: '.js',
|
|
// Plain JavaScript carries types in JSDoc, so every canonical cell is typed
|
|
// that way — `@param` on the entry, `@returns` on the support methods — and
|
|
// the row measures the receiver SPELLING, like every other row. Untyped,
|
|
// each cell measured "the code has no types" instead, and could only flip by
|
|
// guessing from a method name. `untypedChain` keeps one untyped cell as the
|
|
// control: `save` exists on both `User` and `Address`, so an edge there is a
|
|
// guess, and the cell must stay a gap. It deliberately reuses the name
|
|
// `svc`. JSDoc `@param` bindings stay on their own function, unlike Ruby's
|
|
// YARD below, so the control also catches a binding that starts leaking.
|
|
extraShapeIds: ['untypedChain'],
|
|
support: {
|
|
'models.js': `export class Address {
|
|
save() {}
|
|
}
|
|
|
|
export class User {
|
|
constructor() {
|
|
this.address = new Address();
|
|
}
|
|
save() {}
|
|
}
|
|
|
|
export class Service {
|
|
/** @returns {User} */
|
|
getUser() {
|
|
return new User();
|
|
}
|
|
/** @returns {Promise<User>} */
|
|
async getUserAsync() {
|
|
return new User();
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
header: `import { Service, User } from './models.js';\n`,
|
|
wrap: (entry, body) =>
|
|
`/**\n * @param {Service} svc\n * @param {User[]} repos\n */\n` +
|
|
`export async function ${entry}(svc, repos) {\n ${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:models.js:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().save();',
|
|
note: 'control',
|
|
},
|
|
{
|
|
id: 'untypedChain',
|
|
expectedTarget: null,
|
|
member: 'save',
|
|
body: 'svc.getUser().save();',
|
|
raw: `export async function untypedChain(svc) {
|
|
svc.getUser().save();
|
|
}
|
|
`,
|
|
note: 'untyped control — no JSDoc on `svc`; expected to stay a gap',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:models.js:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().address.save();',
|
|
},
|
|
{
|
|
id: 'optionalChain',
|
|
expectedTarget: 'Method:models.js:User.save#0',
|
|
member: 'save',
|
|
body: 'svc?.getUser().save();',
|
|
},
|
|
{ id: 'nonNullAssert', na: 'JavaScript has no non-null assertion operator.' },
|
|
{
|
|
id: 'awaitParen',
|
|
expectedTarget: 'Method:models.js:User.save#0',
|
|
member: 'save',
|
|
body: '(await svc.getUserAsync()).save();',
|
|
},
|
|
{ id: 'explicitTypeArgs', na: 'JavaScript has no type-argument syntax.' },
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:models.js:User.save#0',
|
|
member: 'save',
|
|
body: 'repos[0].save();',
|
|
note: 'JSDoc `User[]`',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:models.js:User.save#0',
|
|
member: 'save',
|
|
body: 'this.repo.save();',
|
|
raw: `class JsHolder {
|
|
constructor() {
|
|
this.repo = new User();
|
|
}
|
|
fieldReceiverCall() {
|
|
this.repo.save();
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
id: 'decoratedReceiverBase',
|
|
na: 'JavaScript method receivers carry no type decoration.',
|
|
},
|
|
{
|
|
id: 'decoratedFieldType',
|
|
na: 'JavaScript fields carry no declared type, so there is no decoration to strip.',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'python',
|
|
ext: '.py',
|
|
support: {
|
|
'models.py': `class Address:
|
|
def save(self) -> None:
|
|
pass
|
|
|
|
|
|
class User:
|
|
def __init__(self) -> None:
|
|
self.address: Address = Address()
|
|
|
|
def save(self) -> None:
|
|
pass
|
|
|
|
|
|
class Service:
|
|
def get_user(self) -> User:
|
|
return User()
|
|
|
|
async def get_user_async(self) -> User:
|
|
return User()
|
|
`,
|
|
},
|
|
header: `from typing import List, Optional\nfrom models import Address, Service, User\n`,
|
|
wrap: (entry, body) => `def ${entry}(svc: Service, repos: List[User]) -> None:\n ${body}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:models.py:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.get_user().save()',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:models.py:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.get_user().address.save()',
|
|
},
|
|
{ id: 'optionalChain', na: 'Python has no optional-chaining operator.' },
|
|
{ id: 'nonNullAssert', na: 'Python has no non-null assertion operator.' },
|
|
{
|
|
id: 'awaitParen',
|
|
expectedTarget: 'Method:models.py:User.save#0',
|
|
member: 'save',
|
|
body: '(await svc.get_user_async()).save()',
|
|
raw: `async def awaitParen(svc: Service) -> None:
|
|
(await svc.get_user_async()).save()
|
|
`,
|
|
note: 'annotated coroutine — the declared return type IS the awaited type',
|
|
},
|
|
{ id: 'explicitTypeArgs', na: 'Python has no call-site type-argument syntax.' },
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:models.py:User.save#0',
|
|
member: 'save',
|
|
body: 'repos[0].save()',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:models.py:User.save#0',
|
|
member: 'save',
|
|
body: 'self.repo.save()',
|
|
raw: `class PyHolder:
|
|
def __init__(self) -> None:
|
|
self.repo: User = User()
|
|
|
|
def fieldReceiverCall(self) -> None:
|
|
self.repo.save()
|
|
`,
|
|
},
|
|
{ id: 'decoratedReceiverBase', na: 'Python `self` carries no type decoration.' },
|
|
{
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Method:models.py:User.save#0',
|
|
member: 'save',
|
|
body: 'self.repo.save()',
|
|
raw: `class PyOptionalHolder:
|
|
def __init__(self) -> None:
|
|
self.repo: Optional[User] = None
|
|
|
|
def decoratedFieldType(self) -> None:
|
|
self.repo.save()
|
|
`,
|
|
note: 'Optional[...] field annotation',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'java',
|
|
ext: '.java',
|
|
support: {
|
|
'Address.java': `public class Address {
|
|
public void save() {}
|
|
}
|
|
`,
|
|
'User.java': `public class User {
|
|
public Address address;
|
|
public void save() {}
|
|
}
|
|
`,
|
|
'Service.java': `import java.util.List;
|
|
|
|
public class Service {
|
|
public User getUser() { return new User(); }
|
|
public <T> User getTyped() { return new User(); }
|
|
}
|
|
`,
|
|
},
|
|
header: `import java.util.List;\n`,
|
|
// Java has no free functions, so each shape gets its own wrapper class. The
|
|
// METHOD is named for the shape, which is what classification matches on.
|
|
wrap: (entry, body) =>
|
|
`class Cap_${entry} {\n void ${entry}(Service svc, User[] repos) {\n ${body}\n }\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:User.java:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().save();',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:Address.java:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().address.save();',
|
|
},
|
|
{ id: 'optionalChain', na: 'Java has no optional-chaining operator.' },
|
|
{ id: 'nonNullAssert', na: 'Java has no non-null assertion operator.' },
|
|
{ id: 'awaitParen', na: 'Java has no await expression in the core language.' },
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
expectedTarget: 'Method:User.java:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.<User>getTyped().save();',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:User.java:User.save#0',
|
|
member: 'save',
|
|
body: 'repos[0].save();',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:User.java:User.save#0',
|
|
member: 'save',
|
|
body: 'this.repo.save();',
|
|
raw: `class JavaHolder {
|
|
User repo;
|
|
void fieldReceiverCall() {
|
|
this.repo.save();
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{ id: 'decoratedReceiverBase', na: 'Java `this` carries no type decoration.' },
|
|
{
|
|
id: 'decoratedFieldType',
|
|
na: "Java's only field-type decorations are containers (arrays, generics), which change the member set and are measured by indexElement rather than by a type-preserving strip.",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'csharp',
|
|
ext: '.cs',
|
|
support: {
|
|
'Models.cs': `using System.Threading.Tasks;
|
|
|
|
public class Address {
|
|
public void Save() {}
|
|
}
|
|
|
|
public class User {
|
|
public Address Address;
|
|
public void Save() {}
|
|
}
|
|
|
|
public class Service {
|
|
public User GetUser() { return new User(); }
|
|
public async Task<User> GetUserAsync() { return new User(); }
|
|
public User GetTyped<T>() { return new User(); }
|
|
}
|
|
`,
|
|
},
|
|
header: `using System.Threading.Tasks;\n`,
|
|
wrap: (entry, body) =>
|
|
`class Cap_${entry} {\n async Task ${entry}(Service svc, User[] repos) {\n ${body}\n }\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:Models.cs:User.Save#0',
|
|
member: 'Save',
|
|
body: 'svc.GetUser().Save();',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:Models.cs:Address.Save#0',
|
|
member: 'Save',
|
|
body: 'svc.GetUser().Address.Save();',
|
|
},
|
|
{
|
|
id: 'optionalChain',
|
|
expectedTarget: 'Method:Models.cs:User.Save#0',
|
|
member: 'Save',
|
|
body: 'svc?.GetUser().Save();',
|
|
},
|
|
{
|
|
id: 'nonNullAssert',
|
|
expectedTarget: 'Method:Models.cs:User.Save#0',
|
|
member: 'Save',
|
|
body: 'svc!.GetUser().Save();',
|
|
},
|
|
{
|
|
id: 'awaitParen',
|
|
expectedTarget: 'Method:Models.cs:User.Save#0',
|
|
member: 'Save',
|
|
body: '(await svc.GetUserAsync()).Save();',
|
|
},
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
expectedTarget: 'Method:Models.cs:User.Save#0',
|
|
member: 'Save',
|
|
body: 'svc.GetTyped<User>().Save();',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:Models.cs:User.Save#0',
|
|
member: 'Save',
|
|
body: 'repos[0].Save();',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:Models.cs:User.Save#0',
|
|
member: 'Save',
|
|
body: 'this.repo.Save();',
|
|
raw: `class CsHolder {
|
|
User repo;
|
|
void fieldReceiverCall() {
|
|
this.repo.Save();
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{ id: 'decoratedReceiverBase', na: 'C# `this` carries no type decoration.' },
|
|
{
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Method:Models.cs:User.Save#0',
|
|
member: 'Save',
|
|
body: 'this.repo.Save();',
|
|
raw: `class CsNullableHolder {
|
|
User? repo;
|
|
void decoratedFieldType() {
|
|
this.repo.Save();
|
|
}
|
|
}
|
|
`,
|
|
note: 'nullable reference-type field',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'ruby',
|
|
ext: '.rb',
|
|
// Typed with YARD for the same reason the JavaScript row uses JSDoc, with
|
|
// `untypedChain` as the one untyped control.
|
|
//
|
|
// Each function names its parameter after its own cell. YARD `@param`
|
|
// bindings are anchored on the method but land on the Module scope, so
|
|
// every function in the file shares ONE binding per parameter name. With a
|
|
// shared `svc`, a cell would resolve through a sibling's tag and the
|
|
// untyped control would borrow one, so neither would measure itself.
|
|
extraShapeIds: ['untypedChain'],
|
|
support: {
|
|
'models.rb': `class Address
|
|
def save; end
|
|
end
|
|
|
|
class User
|
|
attr_reader :address
|
|
def initialize
|
|
@address = Address.new
|
|
end
|
|
def save; end
|
|
end
|
|
|
|
class Service
|
|
# @return [User]
|
|
def get_user
|
|
User.new
|
|
end
|
|
end
|
|
`,
|
|
},
|
|
header: `require_relative 'models'\n`,
|
|
// Unused: every cell below supplies `raw` for its per-cell parameter name.
|
|
wrap: (entry, body) => `def ${entry}(svc, repos)\n ${body}\nend\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:models.rb:User.save#0',
|
|
member: 'save',
|
|
body: 'plain_svc.get_user.save',
|
|
raw: `# @param plain_svc [Service]
|
|
def plainChain(plain_svc)
|
|
plain_svc.get_user.save
|
|
end
|
|
`,
|
|
note: 'control',
|
|
},
|
|
{
|
|
id: 'untypedChain',
|
|
expectedTarget: null,
|
|
member: 'save',
|
|
body: 'untyped_svc.get_user.save',
|
|
raw: `def untypedChain(untyped_svc)
|
|
untyped_svc.get_user.save
|
|
end
|
|
`,
|
|
note: 'untyped control — no YARD on `untyped_svc`; expected to stay a gap',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:models.rb:Address.save#0',
|
|
member: 'save',
|
|
body: 'deep_svc.get_user.address.save',
|
|
raw: `# @param deep_svc [Service]
|
|
def plainDeepChain(deep_svc)
|
|
deep_svc.get_user.address.save
|
|
end
|
|
`,
|
|
},
|
|
{
|
|
id: 'optionalChain',
|
|
expectedTarget: 'Method:models.rb:User.save#0',
|
|
member: 'save',
|
|
body: 'opt_svc&.get_user.save',
|
|
raw: `# @param opt_svc [Service]
|
|
def optionalChain(opt_svc)
|
|
opt_svc&.get_user.save
|
|
end
|
|
`,
|
|
note: 'safe navigation',
|
|
},
|
|
{ id: 'nonNullAssert', na: 'Ruby has no non-null assertion operator.' },
|
|
{ id: 'awaitParen', na: 'Ruby has no await expression in the core language.' },
|
|
{ id: 'explicitTypeArgs', na: 'Ruby has no type-argument syntax.' },
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:models.rb:User.save#0',
|
|
member: 'save',
|
|
body: 'idx_repos[0].save',
|
|
raw: `# @param idx_repos [Array<User>]
|
|
def indexElement(idx_repos)
|
|
idx_repos[0].save
|
|
end
|
|
`,
|
|
note: 'YARD `Array<User>`',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:models.rb:User.save#0',
|
|
member: 'save',
|
|
body: '@repo.save',
|
|
raw: `class RbHolder
|
|
def initialize
|
|
@repo = User.new
|
|
end
|
|
|
|
def fieldReceiverCall
|
|
@repo.save
|
|
end
|
|
end
|
|
`,
|
|
},
|
|
{ id: 'decoratedReceiverBase', na: 'Ruby method receivers carry no type decoration.' },
|
|
{
|
|
id: 'decoratedFieldType',
|
|
na: 'Ruby instance variables carry no declared type, so there is no decoration to strip.',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'rust',
|
|
ext: '.rs',
|
|
support: {
|
|
'models.rs': `pub struct Address;
|
|
|
|
impl Address {
|
|
pub fn save(&self) {}
|
|
}
|
|
|
|
pub struct User {
|
|
pub address: Address,
|
|
}
|
|
|
|
impl User {
|
|
pub fn save(&self) {}
|
|
}
|
|
|
|
pub struct Service;
|
|
|
|
impl Service {
|
|
pub async fn get_user_async(&self) -> User {
|
|
User { address: Address }
|
|
}
|
|
pub fn get_user(&self) -> User {
|
|
User { address: Address }
|
|
}
|
|
pub fn get_typed<T>(&self) -> User {
|
|
User { address: Address }
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
header: `mod models;\nuse models::{Address, Service, User};\n`,
|
|
wrap: (entry, body) => `fn ${entry}(svc: &Service, repos: &Vec<User>) {\n ${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Function:models.rs:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.get_user().save();',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Function:models.rs:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.get_user().address.save();',
|
|
},
|
|
{
|
|
id: 'optionalChain',
|
|
na: 'Rust `?` is error propagation, not optional chaining on a receiver.',
|
|
},
|
|
{ id: 'nonNullAssert', na: 'Rust has no non-null assertion operator.' },
|
|
{
|
|
id: 'awaitParen',
|
|
expectedTarget: 'Function:models.rs:User.save#0',
|
|
member: 'save',
|
|
body: '(svc.get_user_async().await).save();',
|
|
raw: `async fn awaitParen(svc: &Service) {
|
|
(svc.get_user_async().await).save();
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
expectedTarget: 'Function:models.rs:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.get_typed::<User>().save();',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Function:models.rs:User.save#0',
|
|
member: 'save',
|
|
body: 'repos[0].save();',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Function:models.rs:User.save#0',
|
|
member: 'save',
|
|
body: 'self.repo.save();',
|
|
raw: `struct RsHolder {
|
|
repo: User,
|
|
}
|
|
|
|
impl RsHolder {
|
|
fn fieldReceiverCall(&self) {
|
|
self.repo.save();
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
// Rust is the one language besides Go whose method receiver carries
|
|
// written decoration: `&self` / `&mut self` rather than a bare `self`.
|
|
id: 'decoratedReceiverBase',
|
|
expectedTarget: 'Function:models.rs:User.save#0',
|
|
member: 'save',
|
|
body: 'self.repo.save();',
|
|
raw: `struct RsMutHolder {
|
|
repo: User,
|
|
}
|
|
|
|
impl RsMutHolder {
|
|
fn decoratedReceiverBase(&mut self) {
|
|
self.repo.save();
|
|
}
|
|
}
|
|
`,
|
|
note: '&mut self receiver',
|
|
},
|
|
{
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Function:models.rs:User.save#0',
|
|
member: 'save',
|
|
body: 'self.repo.save();',
|
|
raw: `struct RsBoxHolder {
|
|
repo: Box<User>,
|
|
}
|
|
|
|
impl RsBoxHolder {
|
|
fn decoratedFieldType(&self) {
|
|
self.repo.save();
|
|
}
|
|
}
|
|
`,
|
|
note: 'Box<User> — deref-transparent smart pointer',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'c',
|
|
ext: '.c',
|
|
// C has no method dispatch. Its only receiver-shaped call goes through a
|
|
// struct function-pointer field, and that has no static callee: the field
|
|
// produces no graph node, and the function it points at is chosen at run
|
|
// time. So every cell is N/A, including the three that used to be
|
|
// measured (`u->address->save()`, `repos[0].save()`, `u->save()`). They
|
|
// could never resolve, so their gaps measured nothing. Re-checked with the
|
|
// structs below: the receivers now type (`u->address` reaches
|
|
// `User.address`), but neither `save` field has a node to call.
|
|
//
|
|
// The structs live in `main.c`, not in a header. A `.h` file belongs to the
|
|
// C++ provider (`languages/c-cpp.ts`), so structs declared there are
|
|
// invisible to the C resolver. That is a product gap in its own right,
|
|
// rather than something this row should measure by accident. They stay so
|
|
// the dispatch cells can be measured again by swapping each `na` back to
|
|
// its body, if function-pointer fields are ever modelled as callable.
|
|
support: {},
|
|
header: `typedef struct Address {
|
|
void (*save)(void);
|
|
} Address;
|
|
|
|
typedef struct User {
|
|
Address* address;
|
|
void (*save)(void);
|
|
} User;
|
|
`,
|
|
wrap: (entry, body) => `void ${entry}(User* u, User* repos) {\n ${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
na: 'C has no method call on a returned value; dispatch is through struct function-pointer fields only.',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
na: '`u->address->save()`: dispatch through a function-pointer field has no static callee in the graph.',
|
|
},
|
|
{ id: 'optionalChain', na: 'C has no optional-chaining operator.' },
|
|
{ id: 'nonNullAssert', na: 'C has no non-null assertion operator.' },
|
|
{ id: 'awaitParen', na: 'C has no await expression.' },
|
|
{ id: 'explicitTypeArgs', na: 'C has no generics.' },
|
|
{
|
|
id: 'indexElement',
|
|
na: '`repos[0].save()`: dispatch through a function-pointer field has no static callee in the graph.',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
na: '`u->save()`: dispatch through a function-pointer field has no static callee in the graph.',
|
|
},
|
|
{
|
|
id: 'decoratedReceiverBase',
|
|
na: 'C has no method receiver, so there is no receiver decoration.',
|
|
},
|
|
{
|
|
id: 'decoratedFieldType',
|
|
na: 'A C struct field is a function pointer, not a typed receiver whose decoration could be stripped.',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
// Kotlin, Swift and Dart are vendored OPTIONAL grammars shipping only
|
|
// darwin-arm64 prebuilds. On any other host these rows come back
|
|
// GRAMMAR-UNAVAILABLE and are skipped by the gate in both directions, so the
|
|
// cells below are the contract for a host that CAN load them rather than a
|
|
// measurement this machine produced.
|
|
lang: 'kotlin',
|
|
ext: '.kt',
|
|
support: {
|
|
'models.kt': `class Address {
|
|
fun save() {}
|
|
}
|
|
|
|
class User {
|
|
val address: Address = Address()
|
|
fun save() {}
|
|
}
|
|
|
|
class Service {
|
|
fun getUser(): User = User()
|
|
suspend fun getUserAsync(): User = User()
|
|
fun <T> getTyped(): User = User()
|
|
}
|
|
`,
|
|
},
|
|
header: '',
|
|
wrap: (entry, body) =>
|
|
`suspend fun ${entry}(svc: Service, repos: List<User>) {\n ${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:models.kt:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().save()',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:models.kt:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().address.save()',
|
|
},
|
|
{
|
|
id: 'optionalChain',
|
|
expectedTarget: 'Method:models.kt:User.save#0',
|
|
member: 'save',
|
|
body: 'svc?.getUser()?.save()',
|
|
},
|
|
{
|
|
id: 'nonNullAssert',
|
|
expectedTarget: 'Method:models.kt:User.save#0',
|
|
member: 'save',
|
|
body: 'svc!!.getUser().save()',
|
|
},
|
|
{
|
|
id: 'awaitParen',
|
|
expectedTarget: 'Method:models.kt:User.save#0',
|
|
member: 'save',
|
|
body: '(svc.getUserAsync()).save()',
|
|
},
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
expectedTarget: 'Method:models.kt:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.getTyped<User>().save()',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:models.kt:User.save#0',
|
|
member: 'save',
|
|
body: 'repos[0].save()',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:models.kt:User.save#0',
|
|
member: 'save',
|
|
body: 'this.repo.save()',
|
|
raw: `class KtHolder {
|
|
val repo: User = User()
|
|
fun fieldReceiverCall() {
|
|
this.repo.save()
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{ id: 'decoratedReceiverBase', na: 'Kotlin `this` carries no type decoration.' },
|
|
{
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Method:models.kt:User.save#0',
|
|
member: 'save',
|
|
body: 'this.repo?.save()',
|
|
raw: `class KtNullableHolder {
|
|
val repo: User? = null
|
|
fun decoratedFieldType() {
|
|
this.repo?.save()
|
|
}
|
|
}
|
|
`,
|
|
note: 'nullable field type',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'swift',
|
|
ext: '.swift',
|
|
support: {
|
|
'models.swift': `class Address {
|
|
func save() {}
|
|
}
|
|
|
|
class User {
|
|
var address: Address = Address()
|
|
var optionalAddress: Address? = Address()
|
|
func save() {}
|
|
}
|
|
|
|
class Service {
|
|
func getUser() -> User { return User() }
|
|
func getUserAsync() async -> User { return User() }
|
|
}
|
|
`,
|
|
},
|
|
header: '',
|
|
wrap: (entry, body) => `func ${entry}(svc: Service, repos: [User]) async {\n ${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Function:models.swift:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().save()',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Function:models.swift:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().address.save()',
|
|
},
|
|
{
|
|
id: 'optionalChain',
|
|
expectedTarget: 'Function:models.swift:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().optionalAddress?.save()',
|
|
},
|
|
{
|
|
id: 'nonNullAssert',
|
|
expectedTarget: 'Function:models.swift:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().optionalAddress!.save()',
|
|
},
|
|
{
|
|
id: 'awaitParen',
|
|
expectedTarget: 'Function:models.swift:User.save#0',
|
|
member: 'save',
|
|
body: '(await svc.getUserAsync()).save()',
|
|
},
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
na: 'Swift infers generic parameters; there is no call-site type-argument spelling on a method.',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Function:models.swift:User.save#0',
|
|
member: 'save',
|
|
body: 'repos[0].save()',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Function:models.swift:User.save#0',
|
|
member: 'save',
|
|
body: 'self.repo.save()',
|
|
raw: `class SwHolder {
|
|
var repo: User = User()
|
|
func fieldReceiverCall() {
|
|
self.repo.save()
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{ id: 'decoratedReceiverBase', na: 'Swift `self` carries no type decoration.' },
|
|
{
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Function:models.swift:User.save#0',
|
|
member: 'save',
|
|
body: 'self.repo?.save()',
|
|
raw: `class SwOptionalHolder {
|
|
var repo: User? = nil
|
|
func decoratedFieldType() {
|
|
self.repo?.save()
|
|
}
|
|
}
|
|
`,
|
|
note: 'optional field type',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
lang: 'dart',
|
|
ext: '.dart',
|
|
support: {
|
|
'models.dart': `class Address {
|
|
void save() {}
|
|
}
|
|
|
|
class User {
|
|
Address address = Address();
|
|
void save() {}
|
|
}
|
|
|
|
class Service {
|
|
User getUser() => User();
|
|
User getTyped<T>() => User();
|
|
Future<User> getUserAsync() async => User();
|
|
}
|
|
`,
|
|
},
|
|
header: `import 'models.dart';\n`,
|
|
wrap: (entry, body) =>
|
|
`Future<void> ${entry}(Service svc, List<User> repos) async {\n ${body}\n}\n`,
|
|
shapes: [
|
|
{
|
|
id: 'plainChain',
|
|
expectedTarget: 'Method:models.dart:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().save();',
|
|
},
|
|
{
|
|
id: 'plainDeepChain',
|
|
expectedTarget: 'Method:models.dart:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().address.save();',
|
|
},
|
|
{
|
|
id: 'optionalChain',
|
|
expectedTarget: 'Method:models.dart:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().address?.save();',
|
|
},
|
|
{
|
|
id: 'nonNullAssert',
|
|
expectedTarget: 'Method:models.dart:Address.save#0',
|
|
member: 'save',
|
|
body: 'svc.getUser().address!.save();',
|
|
},
|
|
{
|
|
id: 'awaitParen',
|
|
expectedTarget: 'Method:models.dart:User.save#0',
|
|
member: 'save',
|
|
body: '(await svc.getUserAsync()).save();',
|
|
},
|
|
{
|
|
id: 'explicitTypeArgs',
|
|
expectedTarget: 'Method:models.dart:User.save#0',
|
|
member: 'save',
|
|
body: 'svc.getTyped<User>().save();',
|
|
},
|
|
{
|
|
id: 'indexElement',
|
|
expectedTarget: 'Method:models.dart:User.save#0',
|
|
member: 'save',
|
|
body: 'repos[0].save();',
|
|
},
|
|
{
|
|
id: 'fieldReceiverCall',
|
|
expectedTarget: 'Method:models.dart:User.save#0',
|
|
member: 'save',
|
|
body: 'this.repo.save();',
|
|
raw: `class DartHolder {
|
|
User repo = User();
|
|
void fieldReceiverCall() {
|
|
this.repo.save();
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{ id: 'decoratedReceiverBase', na: 'Dart `this` carries no type decoration.' },
|
|
{
|
|
id: 'decoratedFieldType',
|
|
expectedTarget: 'Method:models.dart:User.save#0',
|
|
member: 'save',
|
|
body: 'this.repo?.save();',
|
|
raw: `class DartNullableHolder {
|
|
User? repo;
|
|
void decoratedFieldType() {
|
|
this.repo?.save();
|
|
}
|
|
}
|
|
`,
|
|
note: 'nullable field type',
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
// Exercise the same typed declarations through a real SFC, including extraction
|
|
// and source remapping. The template half of Vue is a separate coverage surface.
|
|
const typescriptCorpus = CORPORA.find((corpus) => corpus.lang === 'typescript');
|
|
CORPORA.push({
|
|
...typescriptCorpus,
|
|
lang: 'vue',
|
|
ext: '.vue',
|
|
header: `<script lang="ts">\n${typescriptCorpus.header}`,
|
|
footer: '</script>\n',
|
|
});
|
|
|
|
/**
|
|
* Every corpus declares a cell for every SHAPE_ID, every `na` carries a reason,
|
|
* and no id is declared twice or unknown. Throws rather than warning: a matrix
|
|
* with a hole reports as if the hole were measured, which is the exact failure
|
|
* the N/A-with-reason rule exists to prevent.
|
|
*/
|
|
function assertMatrixComplete(corpora) {
|
|
const problems = [];
|
|
for (const corpus of corpora) {
|
|
// A language may carry EXTRA cells beyond the canonical axis when they
|
|
// discriminate something the axis cannot — PHP's annotated/unannotated
|
|
// return-type pair, C++'s pointer/value base pair, the JavaScript and Ruby
|
|
// untyped controls. They must be declared, so an extra stays a deliberate
|
|
// diagnostic rather than a typo'd canonical id.
|
|
const known = new Set([...SHAPE_IDS, ...(corpus.extraShapeIds ?? [])]);
|
|
const seen = new Set();
|
|
for (const shape of corpus.shapes) {
|
|
if (!known.has(shape.id)) {
|
|
problems.push(
|
|
`${corpus.lang}: shape id "${shape.id}" is neither canonical nor in extraShapeIds`,
|
|
);
|
|
}
|
|
if (seen.has(shape.id)) problems.push(`${corpus.lang}: duplicate shape id "${shape.id}"`);
|
|
seen.add(shape.id);
|
|
if (shape.na !== undefined && String(shape.na).trim() === '') {
|
|
problems.push(`${corpus.lang}.${shape.id}: N/A with no reason`);
|
|
}
|
|
if (
|
|
shape.na === undefined &&
|
|
!(
|
|
shape.expectedTarget === null ||
|
|
(typeof shape.expectedTarget === 'string' && shape.expectedTarget.length > 0)
|
|
)
|
|
) {
|
|
problems.push(
|
|
`${corpus.lang}.${shape.id}: needs an exact expectedTarget or null for an untyped control`,
|
|
);
|
|
}
|
|
if (shape.na === undefined && shape.body === undefined) {
|
|
problems.push(`${corpus.lang}.${shape.id}: neither a body nor an N/A reason`);
|
|
}
|
|
}
|
|
for (const id of SHAPE_IDS) {
|
|
if (!seen.has(id)) problems.push(`${corpus.lang}: missing cell for "${id}"`);
|
|
}
|
|
}
|
|
if (problems.length > 0) {
|
|
throw new Error(
|
|
`[receiver-resolution] shape matrix is incomplete:\n ${problems.join('\n ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function classify(corpus, result) {
|
|
const calls = [];
|
|
const mainFile = `main${corpus.ext}`;
|
|
for (const rel of result.graph.iterRelationships()) {
|
|
if (rel.type !== 'CALLS') continue;
|
|
const caller = result.graph.getNode(rel.sourceId);
|
|
if (caller?.properties.filePath !== mainFile) continue;
|
|
calls.push({
|
|
from: caller.properties.name,
|
|
to: result.graph.getNode(rel.targetId)?.properties.name ?? '',
|
|
targetId: rel.targetId,
|
|
});
|
|
}
|
|
const drops = (result.resolutionOutcomes ?? []).filter(
|
|
(outcome) =>
|
|
outcome.kind === 'suppressed' &&
|
|
outcome.reason === 'receiver-unresolved' &&
|
|
outcome.siteKind === 'call' &&
|
|
outcome.filePath === mainFile,
|
|
);
|
|
|
|
return corpus.shapes.map((shape) => {
|
|
if (shape.na !== undefined) {
|
|
return { shape: null, id: shape.id, note: shape.na, state: 'N/A', siteKind: null };
|
|
}
|
|
const actualTargets = [
|
|
...new Set(
|
|
calls
|
|
.filter((call) => call.from === shape.id && call.to === shape.member)
|
|
.map((call) => call.targetId),
|
|
),
|
|
].sort();
|
|
const wrongTarget = actualTargets.some((target) => target !== shape.expectedTarget);
|
|
const drop = drops.find(
|
|
(candidate) => candidate.name === shape.member && candidate.shapeId === shape.id,
|
|
);
|
|
return {
|
|
shape: shape.body,
|
|
id: shape.id,
|
|
note: shape.note,
|
|
expectedTarget: shape.expectedTarget,
|
|
actualTargets,
|
|
state: wrongTarget
|
|
? 'WRONG-TARGET'
|
|
: actualTargets.length
|
|
? 'RESOLVES'
|
|
: drop
|
|
? 'VISIBLE-GAP'
|
|
: 'INVISIBLE-GAP',
|
|
siteKind: drop?.siteKind ?? null,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Every cell for a language whose parser could not be loaded. Neither passes
|
|
* nor fails the gate — an unbuilt optional grammar is a fact about the host,
|
|
* not a resolution result. */
|
|
function grammarUnavailableRow(corpus, reason) {
|
|
return corpus.shapes.map((shape) => ({
|
|
shape: shape.body ?? null,
|
|
id: shape.id,
|
|
note: reason,
|
|
state: 'GRAMMAR-UNAVAILABLE',
|
|
siteKind: null,
|
|
}));
|
|
}
|
|
|
|
/** A parse failure caused by a missing vendored grammar, as opposed to a real
|
|
* pipeline error. The vendored loader throws with the grammar package name, and
|
|
* a language whose grammar never loaded produces a graph with no nodes at all. */
|
|
function isGrammarLoadFailure(error) {
|
|
const text = `${error?.message ?? ''}`;
|
|
return (
|
|
text.includes('tree-sitter-') &&
|
|
(text.includes('Cannot find module') ||
|
|
text.includes('No native build') ||
|
|
text.includes('not supported on this platform') ||
|
|
text.includes('prebuild'))
|
|
);
|
|
}
|
|
|
|
async function runShapeArm() {
|
|
assertMatrixComplete(CORPORA);
|
|
const results = [];
|
|
for (const [lang, reason] of Object.entries(NA_LANGUAGES)) {
|
|
results.push({
|
|
language: lang,
|
|
shapes: SHAPE_IDS.map((id) => ({
|
|
shape: null,
|
|
id,
|
|
note: reason,
|
|
state: 'N/A',
|
|
siteKind: null,
|
|
})),
|
|
});
|
|
}
|
|
for (const corpus of CORPORA) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gn-recv-${corpus.lang}-`));
|
|
try {
|
|
for (const [rel, content] of Object.entries(corpus.support)) {
|
|
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(root, rel), content, 'utf8');
|
|
}
|
|
// Each shape's statement gets its own line, so a recorded drop's line
|
|
// identifies which shape produced it without matching on an id.
|
|
const lineOfShape = new Map();
|
|
let text = corpus.header;
|
|
for (const shape of corpus.shapes) {
|
|
// N/A cells are declarations about the language, not code to measure.
|
|
if (shape.na !== undefined) continue;
|
|
// A shape whose receiver needs surrounding structure (a class with a
|
|
// property, say) supplies `raw`; everything else is wrapped in a plain
|
|
// function. Either way the statement itself is `body`, and its offset
|
|
// is found by locating it in the generated block.
|
|
const block = shape.raw ?? corpus.wrap(shape.id, shape.body);
|
|
const blockStartLine = text.split('\n').length;
|
|
const offset = block.split('\n').findIndex((line) => line.includes(shape.body));
|
|
lineOfShape.set(shape.id, blockStartLine + offset);
|
|
text += block;
|
|
}
|
|
text += corpus.footer ?? '';
|
|
fs.writeFileSync(path.join(root, `main${corpus.ext}`), text, 'utf8');
|
|
|
|
let result;
|
|
try {
|
|
result = await runPipelineFromRepo(root, () => {});
|
|
} catch (error) {
|
|
if (!isGrammarLoadFailure(error)) throw error;
|
|
results.push({
|
|
language: corpus.lang,
|
|
shapes: grammarUnavailableRow(corpus, `parser failed to load: ${error.message}`),
|
|
});
|
|
continue;
|
|
}
|
|
// A grammar that soft-fails produces no parse and therefore no nodes.
|
|
// Reporting that as ten resolution gaps would be indistinguishable from a
|
|
// real regression, so it gets its own state.
|
|
if ([...result.graph.iterNodes()].length === 0) {
|
|
results.push({
|
|
language: corpus.lang,
|
|
shapes: grammarUnavailableRow(corpus, 'parser produced no nodes — grammar unavailable'),
|
|
});
|
|
continue;
|
|
}
|
|
// Attach the owning shape to each drop by line before classifying.
|
|
for (const outcome of result.resolutionOutcomes ?? []) {
|
|
for (const [id, line] of lineOfShape) {
|
|
if (outcome.range?.startLine === line) outcome.shapeId = id;
|
|
}
|
|
}
|
|
results.push({ language: corpus.lang, shapes: classify(corpus, result) });
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Count arm
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runCountArm(repoPath) {
|
|
const result = await runPipelineFromRepo(repoPath, () => {});
|
|
const drops = (result.resolutionOutcomes ?? []).filter(
|
|
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
|
|
);
|
|
|
|
const byKind = new Map();
|
|
const byExtension = new Map();
|
|
const callDropsByExtension = new Map();
|
|
// Shape census over the CALL drops only, so the answer to "which kind of
|
|
// receiver are we losing?" is not diluted by property reads and writes.
|
|
const callDropsByShape = new Map();
|
|
// THE number that matters now. A drop whose receiver is rooted outside the
|
|
// indexed program (`System.out.println`, `fetch(...)`) reaches code with no
|
|
// node to point an edge at — nothing was lost. Only `in-program` and
|
|
// `unknown` drops represent a real resolver gap, and only those make an
|
|
// `impact` count a lower bound. Measured on this corpus: 76 external, 20
|
|
// in-program, 6 unknown — i.e. three quarters of the raw count was the
|
|
// program boundary rather than uncertainty about the program.
|
|
const callDropsByOrigin = new Map();
|
|
const callDropsByShapeAndExt = new Map();
|
|
for (const drop of drops) {
|
|
const kind = drop.siteKind ?? '<<unset>>';
|
|
byKind.set(kind, (byKind.get(kind) ?? 0) + 1);
|
|
const ext = path.extname(drop.filePath);
|
|
byExtension.set(ext, (byExtension.get(ext) ?? 0) + 1);
|
|
if (kind === 'call') {
|
|
callDropsByExtension.set(ext, (callDropsByExtension.get(ext) ?? 0) + 1);
|
|
const shape = drop.receiverShape ?? '<<unclassified>>';
|
|
callDropsByShape.set(shape, (callDropsByShape.get(shape) ?? 0) + 1);
|
|
const origin = drop.receiverOrigin ?? '<<unclassified>>';
|
|
callDropsByOrigin.set(origin, (callDropsByOrigin.get(origin) ?? 0) + 1);
|
|
const pair = `${ext} ${shape}`;
|
|
callDropsByShapeAndExt.set(pair, (callDropsByShapeAndExt.get(pair) ?? 0) + 1);
|
|
}
|
|
}
|
|
|
|
const sortDesc = (map) => Object.fromEntries([...map.entries()].sort((a, b) => b[1] - a[1]));
|
|
return {
|
|
repo: repoPath,
|
|
// THE gate number. Property reads and writes are excluded deliberately.
|
|
callDrops: byKind.get('call') ?? 0,
|
|
totalDropsAllKinds: drops.length,
|
|
bySiteKind: sortDesc(byKind),
|
|
callDropsByExtension: sortDesc(callDropsByExtension),
|
|
callDropsByShape: sortDesc(callDropsByShape),
|
|
callDropsByOrigin: sortDesc(callDropsByOrigin),
|
|
callDropsByShapeAndExtension: sortDesc(callDropsByShapeAndExt),
|
|
allDropsByExtension: sortDesc(byExtension),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Perf arm — the U7 thresholds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Wall-clock, peak RSS, persisted chain bytes and cache-dir growth for one
|
|
* pipeline run over a corpus.
|
|
*
|
|
* The A/B control is produced by reverting ONLY the fold wiring
|
|
* (`compound-receiver.ts` + `receiver-bound-calls.ts`) to the pre-U10 commit and
|
|
* rebuilding, so the capture emission — and therefore the persisted bytes — is
|
|
* identical in both arms and the delta isolates the fold itself.
|
|
*/
|
|
/** Every file under `root` with one of `exts`. */
|
|
function walkFiles(root, exts) {
|
|
const out = [];
|
|
const stack = [root];
|
|
while (stack.length > 0) {
|
|
const dir = stack.pop();
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) stack.push(full);
|
|
else if (exts.some((e) => entry.name.endsWith(e))) out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function runPerfArm(repoPath, reps) {
|
|
const timings = [];
|
|
let peakRss = 0;
|
|
let chainSites = 0;
|
|
let chainBytes = 0;
|
|
let referenceSites = 0;
|
|
|
|
for (let i = 0; i < reps; i++) {
|
|
const started = process.hrtime.bigint();
|
|
const result = await runPipelineFromRepo(repoPath, () => {});
|
|
timings.push(Number(process.hrtime.bigint() - started) / 1e6);
|
|
peakRss = Math.max(peakRss, process.memoryUsage().rss);
|
|
|
|
void result;
|
|
}
|
|
|
|
// Persisted chain payload, counted from the emitter rather than from the
|
|
// pipeline result: `PipelineResult` exposes no ParsedFiles, and the emitter is
|
|
// the side that decides what gets written, so this is the authoritative count.
|
|
for (const file of walkFiles(repoPath, ['.ts', '.tsx'])) {
|
|
const src = fs.readFileSync(file, 'utf8');
|
|
for (const match of emitTsScopeCaptures(src, path.relative(repoPath, file))) {
|
|
if (match['@reference.name'] === undefined) continue;
|
|
referenceSites++;
|
|
const chain = match['@reference.receiver-chain'];
|
|
if (chain === undefined) continue;
|
|
chainSites++;
|
|
chainBytes += Buffer.byteLength(chain.text, 'utf8');
|
|
}
|
|
}
|
|
|
|
timings.sort((a, b) => a - b);
|
|
const median = timings[Math.floor(timings.length / 2)];
|
|
return {
|
|
reps,
|
|
wallClockMsMedian: +median.toFixed(1),
|
|
wallClockMsAll: timings.map((t) => +t.toFixed(1)),
|
|
peakRssBytes: peakRss,
|
|
referenceSites,
|
|
chainSites,
|
|
chainBytes,
|
|
bytesPerChainSite: chainSites === 0 ? 0 : +(chainBytes / chainSites).toFixed(1),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const KNOWN_BLIND = [
|
|
'Case 0 is reached by a receiver-TEXT punctuation test (`.` or `(`) OR by a minted receiver chain. A receiver spelled without that punctuation — a subscript, PHP `->`/`::` — therefore records a drop only where its emitter mints a chain; where none is minted the call still vanishes with the recorder blind to it.',
|
|
'A drop is recorded only while `compoundReceiverUnresolved` stays true. When the cascade TYPES the receiver but then finds no member on it, the flag is false and no drop is recorded even though no edge was emitted — so an absent drop is not evidence that a site resolved.',
|
|
'Every count is therefore a LOWER BOUND on a known-biased population. A later delta must be read against the same bias.',
|
|
];
|
|
|
|
/**
|
|
* The gated projection: shape states per language, plus the call-drop counts.
|
|
* Deliberately EXACT rather than budgeted — two consecutive runs are
|
|
* byte-identical, so a range would only hide real movement. Adding fixtures
|
|
* moves these numbers and requires a rebaseline; that treadmill is the accepted
|
|
* cost of the guard, the same trade the scope-capture bench already makes.
|
|
*/
|
|
/**
|
|
* NOTE ON SCOPE: this is the GATED projection — shape states plus call-drop
|
|
* counts. The perf arm (`--perf N`: wall-clock, RSS, bytes/site) is deliberately
|
|
* NOT part of it: those measurements need an A/B against a control build, which
|
|
* `--check` has no way to construct, so asserting them here would compare against
|
|
* numbers from a different machine and fail on noise. The perf figures recorded in
|
|
* BASELINE.md are therefore a POINT-IN-TIME MEASUREMENT, not a CI guard — do not
|
|
* read a green `--check` as evidence that performance has not regressed.
|
|
*/
|
|
function projection(output) {
|
|
return {
|
|
shapeArm: Object.fromEntries(
|
|
output.shapeArm.map((corpus) => [
|
|
corpus.language,
|
|
Object.fromEntries(corpus.shapes.map((shape) => [shape.id, shape.state])),
|
|
]),
|
|
),
|
|
countArm: {
|
|
callDrops: output.countArm.callDrops,
|
|
totalDropsAllKinds: output.countArm.totalDropsAllKinds,
|
|
bySiteKind: output.countArm.bySiteKind,
|
|
callDropsByExtension: output.countArm.callDropsByExtension,
|
|
callDropsByShape: output.countArm.callDropsByShape,
|
|
callDropsByOrigin: output.countArm.callDropsByOrigin,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Every leaf whose value differs, as `dotted.path: expected -> actual`.
|
|
*
|
|
* `GRAMMAR-UNAVAILABLE` is skipped on EITHER side. That state is a fact about
|
|
* the host, not about the code: the optional grammars are absent when a run
|
|
* sets `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or when no vendored prebuild matches
|
|
* the platform, so the same commit legitimately measures those cells on one
|
|
* runner and cannot on another. Gating on them would fail CI for the
|
|
* environment it ran in rather than for a regression — the opposite of what the
|
|
* state was added to prevent. Skipping both directions is what makes "neither
|
|
* passes nor fails the gate" true. */
|
|
function drift(expected, actual, prefix = '') {
|
|
const out = [];
|
|
const keys = new Set([...Object.keys(expected ?? {}), ...Object.keys(actual ?? {})]);
|
|
for (const key of keys) {
|
|
const want = expected?.[key];
|
|
const got = actual?.[key];
|
|
const at = prefix === '' ? key : `${prefix}.${key}`;
|
|
if (want === 'GRAMMAR-UNAVAILABLE' || got === 'GRAMMAR-UNAVAILABLE') continue;
|
|
if (want !== null && typeof want === 'object') out.push(...drift(want, got ?? {}, at));
|
|
else if (want !== got) out.push(`${at}: ${JSON.stringify(want)} -> ${JSON.stringify(got)}`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const corpusIndex = args.indexOf('--corpus');
|
|
const check = args.includes('--check');
|
|
const updateBaseline = args.includes('--update-baseline');
|
|
// `--update-baseline` defaults the corpus for the same reason `--check` does:
|
|
// the two must measure the SAME thing or the gate can never be satisfied. It
|
|
// previously did not, so a bare `--update-baseline` skipped the count arm and
|
|
// then crashed dereferencing `output.countArm` in `projection` — which is the
|
|
// command the `--check` failure message tells you to run. Writing a baseline
|
|
// with no count arm would have been worse: the gate would go green while
|
|
// silently measuring nothing.
|
|
const corpusPath =
|
|
corpusIndex === -1
|
|
? check || updateBaseline
|
|
? DEFAULT_CORPUS
|
|
: undefined
|
|
: path.resolve(args[corpusIndex + 1]);
|
|
|
|
const output = { knownBlind: KNOWN_BLIND };
|
|
// `--update-baseline` needs BOTH arms, because `projection` writes both and
|
|
// `--check` compares both. Running one arm would write a baseline missing the
|
|
// other, and the next `--check` would then crash rather than fail a comparison.
|
|
if (corpusPath === undefined || check || updateBaseline || args.includes('--shapes')) {
|
|
output.shapeArm = await runShapeArm();
|
|
}
|
|
if (corpusPath !== undefined) {
|
|
output.countArm = await runCountArm(corpusPath);
|
|
}
|
|
const perfIndex = args.indexOf('--perf');
|
|
if (perfIndex !== -1) {
|
|
const reps = Number(args[perfIndex + 1] ?? '3');
|
|
output.perfArm = await runPerfArm(
|
|
corpusPath ?? DEFAULT_CORPUS,
|
|
Number.isFinite(reps) ? reps : 3,
|
|
);
|
|
}
|
|
|
|
if (updateBaseline) {
|
|
fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(projection(output), null, 2)}\n`, 'utf8');
|
|
console.error(`[receiver-resolution] wrote ${BASELINE_PATH}`);
|
|
} else if (check) {
|
|
const expected = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
|
|
const diffs = drift(expected, projection(output));
|
|
if (diffs.length > 0) {
|
|
console.error('[receiver-resolution] FAIL — drift against the committed baseline:');
|
|
for (const line of diffs) console.error(` ${line}`);
|
|
console.error(
|
|
'\nIf this is intended (a fixture was added, or a shape genuinely changed state),' +
|
|
'\nre-run with --update-baseline and explain the movement in the commit message.',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.error('[receiver-resolution] OK — shape states and call-drop counts match baseline.');
|
|
} else {
|
|
console.log(JSON.stringify(output, null, 2));
|
|
}
|
|
}
|