diff --git a/.gitattributes b/.gitattributes index faa0f4b261..79670184ee 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,5 @@ worked/**/*.html linguist-vendored=true graphify-out/**/*.html linguist-vendored=true *.html linguist-detectable=false +# Preserve the byte-range fixture's intentional CRLF bytes. +tests/fixtures/qml/parser_probe/UnicodeCRLF.qml -text whitespace=cr-at-eol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6f5e373f7..cf78f84c9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,26 @@ on: push: branches: ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "main"] pull_request: - branches: ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "main"] + branches: ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "main", "codex/qml-*"] workflow_dispatch: +# Checkout/tests/security scanning need no repository mutation permissions. +permissions: + contents: read + +# Obsolete PR revisions share one group. Push/dispatch runs have distinct IDs, +# so later commits cannot cancel required default-branch evidence, even queued. +concurrency: + group: ci-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + GRAPHIFY_PROOF_EVENT: ${{ github.event_name }} + GRAPHIFY_PROOF_WORKFLOW: ${{ github.workflow }} + GRAPHIFY_PROOF_REF: ${{ github.ref }} + GRAPHIFY_PROOF_SOURCE_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + GRAPHIFY_PROOF_BASE: ${{ github.event.pull_request.base.sha || '' }} + jobs: skillgen-check: # Fast lint-style guard: the skill files under graphify/ are generated from @@ -48,6 +65,11 @@ jobs: test: runs-on: ubuntu-latest + env: + GRAPHIFY_FALKORDB_IMAGE: falkordb/falkordb-server:6.0.1@sha256:f4f60c62532b67f8651d0f4f50bbb4db7548717b502bd05abd80e668f2d974c6 + GRAPHIFY_FALKORDB_CONTAINER: graphify-falkor-${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }} + FALKORDB_HOST: localhost + FALKORDB_PORT: "6379" strategy: matrix: python-version: ["3.10", "3.12", "3.13", "3.14"] @@ -70,14 +92,370 @@ jobs: - name: Install dependencies run: uv sync --all-extras --frozen + - name: Prepare reviewed wheel and disposable graph service + # One isolated service per hosted source job; no browser, credentials, + # host mounts or retained database volumes enter the test boundary. + timeout-minutes: 5 + run: | + export GRAPHIFY_PROOF_DIRECTORY="$RUNNER_TEMP/graphify-source-results" + mkdir -p "$GRAPHIFY_PROOF_DIRECTORY" + uv pip freeze --python .venv/bin/python > "$GRAPHIFY_PROOF_DIRECTORY/dependencies.txt" + uv build --wheel --python .venv/bin/python --out-dir "$GRAPHIFY_PROOF_DIRECTORY/wheel" + docker run --detach --name "$GRAPHIFY_FALKORDB_CONTAINER" \ + --publish 127.0.0.1:6379:6379 --pull always \ + --label graphify.ci.run="$GITHUB_RUN_ID" "$GRAPHIFY_FALKORDB_IMAGE" \ + > "$GRAPHIFY_PROOF_DIRECTORY/container-id.txt" + uv run --frozen --no-sync python - <<'PY' + import hashlib, json, os, platform, shutil, subprocess, time + from pathlib import Path + from falkordb import FalkorDB + from redis.backoff import NoBackoff + from redis.retry import Retry + p = Path(os.environ['GRAPHIFY_PROOF_DIRECTORY']) + proof = {key: os.environ.get(key, '') for key in ( + 'GRAPHIFY_PROOF_EVENT', 'GRAPHIFY_PROOF_WORKFLOW', 'GRAPHIFY_PROOF_REF', + 'GRAPHIFY_PROOF_SOURCE_HEAD', 'GRAPHIFY_PROOF_BASE', 'ImageOS', 'ImageVersion')} + proof.update(checkout_sha=subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + python=platform.python_version(), platform=platform.platform()) + assert os.geteuid() != 0, 'Permission acceptance requires the non-root hosted user' + for tool in ('bash', 'sh', 'node', 'cpp'): + assert shutil.which(tool), f'Required source-test consumer unavailable: {tool}' + proof['node_version'] = subprocess.check_output(['node', '--version'], text=True).strip() + proof['cpp_version'] = subprocess.check_output(['cpp', '--version'], text=True).splitlines()[0] + proof['bash_version'] = subprocess.check_output(['bash', '--version'], text=True).splitlines()[0] + proof['git_version'] = subprocess.check_output(['git', '--version'], text=True).strip() + import graphify + assert Path(graphify.__file__).resolve().is_relative_to(Path.cwd() / 'graphify') + proof['production_import'] = str(Path(graphify.__file__).resolve()) + wheels = list((p / 'wheel').glob('*.whl')) + assert len(wheels) == 1, 'Exactly one reviewed source wheel is required' + wheel = wheels[0].resolve() + proof['wheel'] = dict(name=wheel.name, sha256=hashlib.sha256(wheel.read_bytes()).hexdigest()) + with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as environment: + environment.write(f'GRAPHIFY_QML_TEST_WHEEL={wheel}\n') + image = json.loads(subprocess.check_output(['docker', 'image', 'inspect', os.environ['GRAPHIFY_FALKORDB_IMAGE']], text=True))[0] + proof['service_image'] = dict(reference=os.environ['GRAPHIFY_FALKORDB_IMAGE'], + image_id=image['Id'], architecture=image['Architecture'], repo_digests=image['RepoDigests']) + container = json.loads(subprocess.check_output(['docker', 'inspect', os.environ['GRAPHIFY_FALKORDB_CONTAINER']], text=True))[0] + assert not container['Mounts'], 'Disposable graph service must have no mounted storage' + proof['service_container'] = dict(id=container['Id'], mounted_storage_count=len(container['Mounts'])) + (p / 'identity.json').write_text(json.dumps(proof, indent=2), encoding='utf-8') + deadline, ready, client = time.monotonic() + 45, False, None + while time.monotonic() < deadline: + try: + # Construction itself probes INFO server in the SDK; keep it + # inside the startup deadline rather than racing first boot. + client = FalkorDB(host='localhost', port=6379, socket_connect_timeout=1, + socket_timeout=1, retry=Retry(NoBackoff(), 0)) + assert client.connection.execute_command('GRAPH.LIST') == [] + ready = True + break + except Exception: + if client is not None: + client.connection.close() + time.sleep(.5) + assert ready, 'FalkorDB GRAPH.LIST readiness unavailable within the bounded deadline' + probe = client.select_graph('graphify_ci_probe') + try: + assert probe.query('RETURN 1', timeout=1000).result_set == [[1]] + finally: + probe.delete() + assert client.connection.execute_command('GRAPH.LIST') == [], 'Readiness graph cleanup failed' + modules = [] + for value in client.connection.module_list(): + # RESP2 may expose alternating pairs; retain only safe public + # module identity, never its arguments or filesystem path. + module = value if isinstance(value, dict) else dict(zip(value[::2], value[1::2])) + module = {(key.decode() if isinstance(key, bytes) else key): val for key, val in module.items()} + modules.append({key: (val.decode() if isinstance(val, bytes) else val) for key, val in module.items() if key in ('name', 'ver')}) + proof['service_ready'] = dict(graph_list=True, graph_query=True, probe_deleted=True, + redis_version=client.connection.info('server')['redis_version'], + modules=modules) + client.connection.close() + (p / 'identity.json').write_text(json.dumps(proof, indent=2), encoding='utf-8') + PY + - name: Run tests - run: uv run --frozen pytest tests/ -q --tb=short + run: | + export GRAPHIFY_PROOF_DIRECTORY="$RUNNER_TEMP/graphify-source-results" + set +e + uv run --frozen --no-sync python -X utf8 -m pytest tests/ -q --tb=short -rs \ + --junitxml="$GRAPHIFY_PROOF_DIRECTORY/tests.xml" | tee "$GRAPHIFY_PROOF_DIRECTORY/tests.log" + outcomes=("${PIPESTATUS[@]}") + printf '%s\n' "${outcomes[0]}" > "$GRAPHIFY_PROOF_DIRECTORY/pytest-exit.txt" + if [ "${outcomes[0]}" -ne 0 ]; then exit "${outcomes[0]}"; fi + exit "${outcomes[1]}" + + - name: Verify source and dependency retention + if: always() + run: | + export GRAPHIFY_PROOF_DIRECTORY="$RUNNER_TEMP/graphify-source-results" + uv pip freeze --python .venv/bin/python > "$GRAPHIFY_PROOF_DIRECTORY/dependencies-after.txt" + uv run --frozen --no-sync python - <<'PY' + import hashlib, json, os, subprocess, xml.etree.ElementTree as ET + from pathlib import Path + from falkordb import FalkorDB + from redis.backoff import NoBackoff + from redis.retry import Retry + p = Path(os.environ['GRAPHIFY_PROOF_DIRECTORY']) + identity = json.loads((p / 'identity.json').read_text(encoding='utf-8')) + paths = ['graphify', 'tests', 'tools', 'uv.lock', 'pyproject.toml', '.github/workflows'] + proof = dict(checkout_unchanged=identity['checkout_sha'] == subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + tracked_inputs_unchanged=subprocess.run(['git', 'diff', '--quiet', 'HEAD', '--', *paths]).returncode == 0, + dependencies_unchanged=(p / 'dependencies.txt').read_bytes() == (p / 'dependencies-after.txt').read_bytes(), + wheel_unchanged=identity['wheel']['sha256'] == hashlib.sha256(Path(os.environ['GRAPHIFY_QML_TEST_WHEEL']).read_bytes()).hexdigest()) + # Read the existing production tests' outcomes; readiness alone cannot + # make an auto-skipped live service or wheel case accepted evidence. + required = { + 'tests.test_falkordb_integration': {'test_push_to_falkordb_creates_expected_graph', 'test_push_to_falkordb_is_idempotent'}, + 'tests.test_qml_wheel_artifact': {'test_qml001_ac01_built_wheel_contains_adapter_and_optional_extra_metadata', + 'test_qml001_ac01_ac03_built_artifact_production_import_and_parser_boundary[qml]', + 'test_qml001_ac01_ac03_built_artifact_production_import_and_parser_boundary[core]'}} + cases = list(ET.parse(p / 'tests.xml').iter('testcase')) + for module, expected in required.items(): + selected = [case for case in cases if case.get('classname') == module] + proof[module] = len(selected) == len(expected) and {case.get('name') for case in selected} == expected and all(not any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error')) for case in selected) + # Joint producer/consumer cases must execute, not merely collect; + # retained JUnit binds their complete reviewed matrix to this checkout. + integrated = {'tests.test_upstream_qt_cache_integration': 6, + 'tests.test_upstream_qt_discovery_integration': 11, + 'tests.test_upstream_qt_export_integration': 10, + 'tests.test_upstream_qt_path_integration': 3, + 'tests.test_upstream_qt_shared_integration': 4} + for module, count in integrated.items(): + selected = [case for case in cases if case.get('classname') == module] + proof[module] = len(selected) == count and all(not any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error')) for case in selected) + client = None + proof['test_graphs_deleted'] = False + try: + client = FalkorDB(host='localhost', port=6379, socket_connect_timeout=1, + socket_timeout=2, retry=Retry(NoBackoff(), 0)) + remaining = [value.decode('utf-8') if isinstance(value, bytes) else value + for value in client.connection.execute_command('GRAPH.LIST')] + proof['test_graphs_deleted'] = remaining == [] + # Failed fixture cleanup cannot pass the gate. Dispose of its + # bounded public test graph before the container is removed. + for name in ('graphify_test', 'graphify_ci_probe'): + if name in remaining: + client.select_graph(name).delete() + except Exception: + proof['test_graphs_deleted'] = False + finally: + if client is not None: + client.connection.close() + (p / 'integrity.json').write_text(json.dumps(proof, indent=2), encoding='utf-8') + assert all(proof.values()), 'Source/dependency preservation or required artifact/service acceptance failed' + PY + + - name: Remove disposable graph service + if: always() + # Cleanup has its own always step so failed setup, pytest, or integrity + # cannot leave the job-owned service running. No shared containers prune. + run: | + export GRAPHIFY_PROOF_DIRECTORY="$RUNNER_TEMP/graphify-source-results" + mkdir -p "$GRAPHIFY_PROOF_DIRECTORY" + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + name = os.environ['GRAPHIFY_FALKORDB_CONTAINER'] + listed = subprocess.check_output(['docker', 'ps', '--all', '--quiet', '--filter', f'name=^/{name}$'], text=True).strip() + if listed: + label = subprocess.check_output(['docker', 'inspect', '--format', '{{ index .Config.Labels "graphify.ci.run" }}', name], text=True).strip() + assert label == os.environ['GITHUB_RUN_ID'], 'Refuse cleanup of an unowned service' + subprocess.run(['docker', 'rm', '--force', '--volumes', name], check=True) + absent = not subprocess.check_output(['docker', 'ps', '--all', '--quiet', '--filter', f'name=^/{name}$'], text=True).strip() + Path(os.environ['GRAPHIFY_PROOF_DIRECTORY'], 'service-cleanup.json').write_text(json.dumps(dict(container_absent=absent)), encoding='utf-8') + assert absent, 'Disposable service cleanup unconfirmed' + PY + + - name: Retain source test evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ubuntu-source-python-${{ matrix.python-version }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/graphify-source-results/ + retention-days: 14 + if-no-files-found: warn - name: Verify install works end-to-end run: | uv run --frozen graphify --help uv run --frozen graphify install + windows-compatibility: + # Ubuntu owns complete source regression and real POSIX objects. This lane + # covers the generic native Windows contracts omitted by the QML wheel job. + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.12" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + # Same interpreter used by the reviewed local emitted-script proof. + node-version: "24.19.0" + package-manager-cache: false + - name: Install all locked optional test dependencies + run: uv sync --all-extras --frozen + - name: Verify native Windows compatibility + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $proofDirectory = Join-Path $env:RUNNER_TEMP 'graphify-compat-results' + New-Item -ItemType Directory -Path $proofDirectory -Force | Out-Null + $proofPython = (Resolve-Path '.venv/Scripts/python.exe').Path + # Hosted PATH can expose multiple copies; retain its first application. + $proofUv = (Get-Command uv -CommandType Application | Select-Object -First 1).Source + $proofNode = (Get-Command node -CommandType Application | Select-Object -First 1).Source + $nodeDirectory = Split-Path -Path $proofNode -Parent + $gitDirectory = Join-Path $env:ProgramFiles 'Git/usr/bin' + $gitCommandDirectory = Join-Path $env:ProgramFiles 'Git/cmd' + foreach ($name in @('bash.exe', 'sh.exe', 'cygpath.exe')) { + if (-not (Test-Path -LiteralPath (Join-Path $gitDirectory $name) -PathType Leaf)) { + throw "The hosted Git for Windows shell component is unavailable: $name" + } + } + & $proofUv pip check --python $proofPython + if ($LASTEXITCODE -ne 0) { throw 'The locked test environment is incompatible' } + & $proofUv pip freeze --python $proofPython | Set-Content (Join-Path $proofDirectory 'dependencies.txt') -Encoding utf8 + if ($LASTEXITCODE -ne 0) { throw 'Initial dependency evidence unavailable' } + $productionImport = (& $proofPython -c 'import graphify; print(graphify.__file__)') + if ($LASTEXITCODE -ne 0 -or [IO.Path]::GetFullPath($productionImport) -ne (Resolve-Path 'graphify/__init__.py').Path) { + throw 'The source test interpreter imported a different Graphify checkout' + } + $productionImport | Set-Content (Join-Path $proofDirectory 'production-import.txt') -Encoding utf8 + $checkoutSha = git rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $checkoutSha -notmatch '^[a-f0-9]{40}$') { throw 'Initial checkout revision unavailable' } + $identity = [ordered]@{ + workflow = $env:GRAPHIFY_PROOF_WORKFLOW; event = $env:GRAPHIFY_PROOF_EVENT + ref = $env:GRAPHIFY_PROOF_REF; source_head = $env:GRAPHIFY_PROOF_SOURCE_HEAD + base_sha = $env:GRAPHIFY_PROOF_BASE; checkout_sha = $checkoutSha + image_os = $env:ImageOS; image_version = $env:ImageVersion + python = (& $proofPython --version); git = (git --version) + node = (& $proofNode --version) + cmd = $env:COMSPEC; windows_powershell = (Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe') + windows_powershell_version = (& (Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe') -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.ToString()') + bash = (& (Join-Path $gitDirectory 'bash.exe') --version | Select-Object -First 1) + } + $identity | ConvertTo-Json | Set-Content (Join-Path $proofDirectory 'identity.json') -Encoding utf8 + # Bash blocks call python3. Forward to the exact proof interpreter, + # reject installer fallback, and expose only this process-local PATH. + $shimDirectory = Join-Path $env:RUNNER_TEMP 'graphify-compat-bin' + New-Item -ItemType Directory -Path $shimDirectory -Force | Out-Null + $shim = @' + #!/bin/sh + if [ "$1" = "-m" ] && [ "$2" = "pip" ]; then + echo "pip installation is disabled in validation" >&2; exit 86 + fi + exec "$GRAPHIFY_TEST_PYTHON" "$@" + '@ + [IO.File]::WriteAllText((Join-Path $shimDirectory 'python3'), $shim.Replace("`r`n", "`n") + "`n", [Text.UTF8Encoding]::new($false)) + $env:GRAPHIFY_TEST_PYTHON = $proofPython + $env:GRAPHIFY_TEST_UV = $proofUv + $env:PATH = @($shimDirectory, $gitDirectory, $gitCommandDirectory, $nodeDirectory, (Join-Path $env:SystemRoot 'System32')) -join ';' + $env:PYTHONUTF8 = '1' + $env:GRAPHIFY_NO_AUTO_REFRESH = '1' + $env:GRAPHIFY_NO_TIPS = '1' + $env:UV_OFFLINE = '1' + $env:UV_TOOL_DIR = Join-Path $proofDirectory 'isolated-tools' + $env:UV_TOOL_BIN_DIR = Join-Path $proofDirectory 'isolated-tool-bin' + $env:PIP_NO_INDEX = '1' + $env:PIP_TARGET = Join-Path $proofDirectory 'isolated-pip' + $env:APPDATA = Join-Path $proofDirectory 'isolated-appdata' + $env:COVERAGE_FILE = Join-Path $proofDirectory '.coverage' + foreach ($name in @('BASH_ENV', 'ENV', 'PYTHONHOME', 'PYTEST_ADDOPTS')) { + Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue + } + foreach ($name in @('uv', 'pip', 'graphify')) { + if (Get-Command $name -CommandType Application -ErrorAction SilentlyContinue) { + throw "Validation PATH unexpectedly exposes an installer or launcher: $name" + } + } + # Use the ordinary Python transport: native bare-name search can launch + # the System32 WSL stub even when PATH lookup admits real Git Bash. + $consumerProof = @' + import json, os, subprocess, sys + from pathlib import Path + from tests.shell_portability import select_shell_executable + admitted = {} + for name in ('bash', 'sh'): + executable = select_shell_executable(name) + assert Path(executable).samefile(Path(sys.argv[1], name + '.exe')), 'Wrong shell installation' + proof = {'executable': executable} + for consumer, command, expected in ( + ('python', "python3 -c 'import sys; print(sys.executable)'", os.environ['GRAPHIFY_TEST_PYTHON']), + ('node', "node -p 'process.execPath'", sys.argv[2]), + ): + actual = subprocess.check_output([executable, '-c', command], text=True).strip() + assert Path(actual).samefile(expected), 'Wrong consumer executable' + proof[consumer] = actual + admitted[name] = proof + print(json.dumps(admitted)) + '@ + & $proofPython -c $consumerProof $gitDirectory $proofNode | + Set-Content (Join-Path $proofDirectory 'shell-consumers.json') -Encoding utf8 + if ($LASTEXITCODE -ne 0) { throw 'The admitted native shell consumer cannot execute' } + if ((& $proofNode --version) -ne 'v24.19.0') { throw 'The reviewed Node.js test interpreter was not selected' } + $selection = @( + 'tests/test_filesystem_profiles.py', 'tests/test_source_input_admission.py', 'tests/test_source_identity_remap_owners.py', + 'tests/test_install.py', 'tests/test_install_references.py', 'tests/test_install_roundtrip.py', + 'tests/test_skill_auto_refresh.py', 'tests/test_uninstall_scope.py', 'tests/test_settings_merge.py', + 'tests/test_hooks.py', 'tests/test_skillgen_input_path_injection.py', 'tests/test_shell_portability_helpers.py', + 'tests/test_watch.py', 'tests/test_source_alias_provenance.py', 'tests/test_qt_update_discovery_profile.py', 'tests/test_watch_physical_coowners.py', 'tests/test_watch_coowner_admission.py', 'tests/test_non_regular_files.py', 'tests/test_terraform.py', + 'tests/test_terraform_modules.py', 'tests/test_extractors_registry.py::test_terraform_migrated', 'tests/test_ci_windows_tools.py' + # The merged upstream cache/discovery/consumer contracts also run + # under this admitted native interpreter, with retained case proof. + 'tests/test_upstream_qt_cache_integration.py', 'tests/test_upstream_qt_discovery_integration.py', + 'tests/test_upstream_qt_export_integration.py', 'tests/test_upstream_qt_path_integration.py', + 'tests/test_upstream_qt_shared_integration.py', 'tests/test_qt_followed_metadata_alias.py', + 'tests/test_extract.py::test_python_external_calls_survive_real_incremental_context' + 'tests/test_qt_source_identity_admission.py' + ) + & $proofPython -X utf8 -m pytest @selection -q --tb=short -p no:cacheprovider -rs "--junitxml=$proofDirectory/tests.xml" 2>&1 | + Tee-Object -FilePath (Join-Path $proofDirectory 'tests.log') + $testExit = $LASTEXITCODE + $testExit | Set-Content (Join-Path $proofDirectory 'pytest-exit.txt') -Encoding utf8 + $auditExit = 0 + try { + & $proofUv pip freeze --python $proofPython | Set-Content (Join-Path $proofDirectory 'dependencies-after.txt') -Encoding utf8 + if ($LASTEXITCODE -ne 0) { throw 'Final dependency evidence unavailable' } + $before = Get-Content -LiteralPath (Join-Path $proofDirectory 'dependencies.txt') + $after = Get-Content -LiteralPath (Join-Path $proofDirectory 'dependencies-after.txt') + $dependencyRetained = -not (Compare-Object $before $after) + $currentHead = git rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $currentHead -notmatch '^[a-f0-9]{40}$') { throw 'Final checkout revision unavailable' } + $headRetained = $currentHead -eq $identity.checkout_sha + git diff --quiet HEAD -- graphify tests tools uv.lock pyproject.toml .github/workflows + $inputsRetained = $LASTEXITCODE -eq 0 + $integrity = [ordered]@{dependencies_unchanged=$dependencyRetained; checkout_unchanged=$headRetained; tracked_inputs_unchanged=$inputsRetained} + $integrity | ConvertTo-Json | Set-Content (Join-Path $proofDirectory 'integrity.json') -Encoding utf8 + if (-not ($dependencyRetained -and $headRetained -and $inputsRetained)) { throw 'Proof inputs changed' } + } catch { + Write-Host 'Source/dependency preservation proof failed; review the retained integrity evidence.' + $auditExit = 1 + } + if ($testExit -ne 0) { exit $testExit } + exit $auditExit + - name: Retain native compatibility evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: windows-compat-python-3.12-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/graphify-compat-results/identity.json + ${{ runner.temp }}/graphify-compat-results/shell-consumers.json + ${{ runner.temp }}/graphify-compat-results/production-import.txt + ${{ runner.temp }}/graphify-compat-results/dependencies*.txt + ${{ runner.temp }}/graphify-compat-results/tests.* + ${{ runner.temp }}/graphify-compat-results/pytest-exit.txt + ${{ runner.temp }}/graphify-compat-results/integrity.json + retention-days: 14 + if-no-files-found: warn + security-scan: # The dev deps include bandit and pip-audit. Run them in CI so a new # HIGH-severity finding or vulnerable dependency is caught on the PR that diff --git a/.github/workflows/qml-wheel.yml b/.github/workflows/qml-wheel.yml new file mode 100644 index 0000000000..fc432d5a74 --- /dev/null +++ b/.github/workflows/qml-wheel.yml @@ -0,0 +1,38 @@ +name: QML optional wheel + +on: + pull_request: + branches: [v8, main, 'codex/qml-*'] + push: + branches: [v8, main] + +permissions: + contents: read + +# PR revisions replace obsolete evidence; distinct non-PR groups also prevent +# GitHub's pending-run replacement from dropping queued default-branch proof. +concurrency: + group: qml-wheel-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + qml-wheel: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ['3.10', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@v6 + with: + # Qt assistant guidance checks read the immutable pre-split baseline. + # A shallow checkout cannot execute those source-policy regressions. + fetch-depth: 0 + - uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ matrix.python }} + # Existing Ubuntu all-extras CI owns source regressions. These lanes prove + # isolated optional/core wheel installs; Windows/macOS add QML source tests. + - name: Verify installed wheels and platform contracts + run: uv run --no-project --python ${{ matrix.python }} python tests/qml_ci.py ${{ runner.os == 'Linux' && '--artifact-only' || '' }} diff --git a/.gitignore b/.gitignore index 0a6775b2a8..2673178ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ venv/ .venv/ +.venv-posix/ env/ __pycache__/ *.pyc diff --git a/.graphifyignore b/.graphifyignore new file mode 100644 index 0000000000..288250cf76 --- /dev/null +++ b/.graphifyignore @@ -0,0 +1,4 @@ +# Deliberately invalid parser fixtures are test data, not publishable components. +# Tests open these files directly; repository graph refresh follows this corpus. +tests/fixtures/qml/parser_probe/Malformed.qml +tests/fixtures/qml/parser_probe/Incomplete.qml diff --git a/README.md b/README.md index ecab496d05..bd3229f311 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,18 @@ What you get out of the box: | **Beyond code** | Docs, PDFs, images, and video/audio all map into the same graph | | **Local-first** | Code is parsed locally with tree-sitter (no LLM, nothing leaves your machine); only the semantic pass over docs/media calls a backend, and only if you configure one | +### Qt and QML source analysis + +This branch includes optional Qt 6/QML static analysis. From this checkout, +`uv sync --extra qml --extra watch` installs the pinned QML parser and watch support. +It covers scoped QML/JavaScript dependencies, Qt C++ signals/slots/connections, +both C++/QML integration directions, and literal CMake/qmake/resource metadata. +Computed runtime targets remain explicitly unresolved; analysis runs no Qt SDK, +build tool or application code. See the [support and installation evidence](docs/qt-qml/README.md), +[export contracts](docs/qt-qml/EXPORT_MATRIX.md) and +[acceptance criteria](docs/REQUIREMENTS.md) for the verified subset and limits. +Upstream merging and package publication remain separate delivery steps. + --- ## Benchmarks diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 0000000000..0471564182 --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,549 @@ +# Shared compatibility verification + +This document records the complete fork's installed-assistant contracts, +portable graph provenance and filesystem test profiles. The current +[upstream contribution scope](qt-qml/UPSTREAM_CONTRIBUTION.md) retains +REQ-CORE-001-AC02–AC04 and REQ-CORE-002–004, with their applicable profile +assignments. It defers the independent native Codex feature under +REQ-CORE-001-AC01/AC05 and DEC-CORE-01; the contribution restores the upstream +installer contract. Runtime ownership, parser contracts and Qt policy/cache +epochs remain governed by the existing architecture. + +The complete fork is frozen at +[`82a4f29`](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4). +The [verified integrated checkpoint](../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2) +and all earlier results below remain proof of their recorded source/profile. +They do not verify the narrowed contribution head; fresh applicable evidence +belongs to [traceability](../tests/TRACEABILITY.md). + +Requirements use `REQ-CORE-NNN`, acceptance criteria `REQ-CORE-NNN-ACNN`, and +increments `INC-CORE-NN`. The independent counters begin at 001/01 and do not +renumber or reuse the Qt/QML catalog. [Requirements](REQUIREMENTS.md) own behavior; +[traceability](../tests/TRACEABILITY.md) owns results. An unavailable fixture is +not a passing assertion. + +## Plan and acceptance matrix + +This historical matrix retains each increment and its original acceptance +identity. INC-CORE-01 has mixed disposition: AC01 is deferred for this +contribution, while AC02–AC04 remain applicable. INC-CORE-05 is deferred in full. +Retained infrastructure increments exclude the deferred native-hook assignment +from their current profile selection without weakening the other contracts. + +| Increment | Outcome | Acceptance | Exit condition | +| --- | --- | --- | --- | +| INC-CORE-01 | Executable Codex hooks and portable installed-assistant fixture contracts | REQ-CORE-001-AC01–AC04 | Real dispatch from a path with spaces, exact platform destinations, refresh and uninstall regressions pass; generated skills unchanged | +| INC-CORE-02 | Explicit filesystem profiles with native failure-path protection | REQ-CORE-002-AC01–AC02 | Native classification/recovery assertions pass; actual unsupported FIFO/socket/symlink/deleted-CWD fixtures retain a separately recorded POSIX execution gap | +| INC-CORE-03 | Terraform scope and portable serialized provenance | REQ-CORE-003-AC01–AC02 | LF/CRLF source facts and directed references survive graph assembly/JSON reload without cross-directory binding | +| INC-CORE-04 | Accurate unavailable-CWD recovery diagnostics | REQ-CORE-002-AC03 | Supplied-root change-directory failure is distinguished from an unset root; prior output retained and private paths excluded | +| INC-CORE-05 | Native hook consumer and literal-path completion | REQ-CORE-001-AC01/AC05 | Exact Cmd and PowerShell argument transport, expansion-sensitive paths, dependency rejection and hook ownership; full-source and installed-artifact proof | +| INC-CORE-06 | Hosted Linux evidence and native Windows compatibility lane | REQ-CORE-004-AC01–AC04 | Required tools/service and reviewed wheel admitted; workflow syntax/configuration reviewed; hosted jobs retain applicable passing results and exact revision/integrity evidence | +| INC-CORE-07 | Deterministic hosted Windows tool selection | REQ-CORE-004-AC02/AC03 | Multiple real application matches preserve the first PATH identity, required version and executable invocation; missing tools reject before collection; normal PR evidence records the corrected revision | +| INC-CORE-08 | Preserve admitted shell identity at native process launch | REQ-CORE-004-AC02/AC03 | Actual Python subprocesses execute the selected absolute Bash/sh file, preserve literal/security assertions and reject missing shells; corrected normal PR results retain exact identity and outcomes | +| INC-CORE-09 | Retain native shell consumer identity evidence | REQ-CORE-004-AC03 | The actual uploaded job artifact includes and verifies both shells' executable, Python and Node identities for the reviewed checkout; omitted or inconsistent evidence cannot establish acceptance | +| INC-CORE-10 | Admit the real native external-call subprocess fixture | REQ-CORE-004-AC02 | Required isolated Windows home/system fields reach the child while all ten extraction assertions remain; the exact native and four Linux cases pass at the integrated checkpoint | + +At their recorded fork checkpoints, INC-CORE-01–05 have native source evidence +below. The hook/filesystem corrections also have independent installed-consumer +evidence. INC-CORE-06 closes the former +POSIX filesystem gap and the missing Windows generic-contract CI selection at +the [verified hosted checkpoint](../tests/TRACEABILITY.md#verified-hosted-runner-checkpoint--10cb15a); it changes test +infrastructure, with no Graphify extraction or Qt schema change. Existing +all-extras Ubuntu source jobs and the separate QML-wheel matrix remain the +starting point. Configuration acceptance and hosted execution are distinct. +Both workflows use the PR number for obsolete-PR cancellation and a distinct +run ID for non-PR groups. The QML-wheel correction replaces its former branch-ref +fallback, which could replace an older queued non-PR proof even when running-job +cancellation was disabled. Its triggers, matrix and test jobs are unchanged. + +The environment uses Python 3.12 and `uv sync --all-extras --frozen`. This +installs the committed dependency versions without changing the lockfile. +Portable Git supplies Bash/sh for Windows shell tests. SDK tests use isolated +provider fakes; installing an SDK does not establish live-provider access. + +Shell fixtures send literal input paths in the receiving Python interpreter's +format. A native Windows interpreter must not receive a fabricated WSL path. +MSYS shell output may use a different spelling for the same interpreter; the +test transport uses that shell's actual path converter before comparing complete +identities. Basenames or substring matches are insufficient. + +Gemini's Windows user skill uses the shared `.agents` directory, while POSIX +uses `.gemini`. Hermes follows the existing platform-owned home/data directory. +Windows installs and refreshes use the existing adapted skill content. Shared +Gemini/agents uninstall retention is deliberate and remains covered. Corrections +to stale fixture expectations do not change these product contracts. + +The historical upstream review records PR +[#3268](https://github.com/Graphify-Labs/graphify/pull/3268), which proposed bare +Codex commands for default installs. The deferred fork correction instead retained +the existing explicit executable contract and fixed literal invocation. PR +[#2802](https://github.com/Graphify-Labs/graphify/pull/2802) independently addresses +Windows destination fixtures. Upstream issue +[#2800](https://github.com/Graphify-Labs/graphify/issues/2800) tracks Gemini/agents +shared-directory collision; current tests do not establish ownership isolation +or resolve that upgrade limitation. Incoming upstream `v8` was inspected at +`35adf43`; no incoming commits were integrated in that follow-up. Subsequent +integration and the current contribution scope are recorded separately in +[UPSTREAM_CONTRIBUTION.md](qt-qml/UPSTREAM_CONTRIBUTION.md). +The requirement review corrected the draft refresh criterion to describe only +uniquely owned destinations: the production refresh deliberately leaves ambiguous +shared copies unchanged. That retained behavior has exact regression assertions; +it does not establish a fix for upstream issue #2800. + +Terraform source files use portable POSIX separators. Same-name declarations +in distinct directories retain distinct identities and only same-directory +references. The former Windows failure occurred before assessing any target: +the test searched for a backslash path while the extractor emitted the correct +portable path. No Terraform production change is needed for that defect. + +## Verification profiles and remaining system work + +The local setup and WSL sequence below is historical. Its pending Linux work was +subsequently addressed by the recorded hosted profiles; it is not a current +claim that the contribution lacks those retained fixture contracts. New-head +verification remains separate from that earlier hosted evidence. + +Native Windows cannot create Unix FIFOs through `os.mkfifo` or remove its current +working directory. Symlink creation also depends on actual account capability. +Capability/profile exclusions leave the real POSIX assertions intact and do not +substitute simulated evidence for a real filesystem result. Windows production +failure seams supplement those assertions. + +WSL is absent on the inspected host, and the agent session is not elevated. +[Microsoft's installation procedure](https://learn.microsoft.com/en-us/windows/wsl/install) +requires an administrator terminal and may require a restart. A Linux run remains +outstanding until that environment is available; package installation or Bash +alone cannot establish POSIX kernel behavior. No operating-system security policy +is changed to manufacture a passing fixture. + +After the installation restart, WSL 3.0.1.0 is present but no distribution is +registered. Ubuntu download succeeds; registration fails with +`HCS_E_HYPERV_NOT_INSTALLED`. Windows reports firmware virtualization disabled, +although the processor supports virtualization and second-level address +translation. Optional-component and boot settings cannot be verified from the +non-elevated session. Linux execution remains pending until the firmware/WSL +profile is available; no Linux passes are inferred from the Windows seam tests. +[Microsoft's troubleshooting procedure](https://learn.microsoft.com/en-us/windows/wsl/troubleshooting#installation-issues) +describes the firmware and Virtual Machine Platform prerequisites. + +Prepare the native environment from the repository root: + +```powershell +uv sync --all-extras --frozen +uv pip check --python .venv/Scripts/python.exe +``` + +Expose the portable Git installation's `usr/bin` directory in the test process +PATH to make both `bash` and `sh` available. Keep the test provider environment +isolated and use `uv run --no-sync` or the environment's Python to preserve all +installed extras while verifying. A missing Bash tool is a setup gap, not a +reason to classify shell security cases as successful. + +For the remaining real Linux profile, run this in an administrator PowerShell +terminal, then restart if Windows requests it: + +```powershell +wsl --install -d Ubuntu --no-launch +``` + +After Ubuntu initialization, enter the mounted repository root from its Bash +shell and create an independent ignored environment, preserving `.venv` for +Windows. Install uv using its official instructions before these commands: + +```bash +UV_PROJECT_ENVIRONMENT=.venv-posix uv sync --all-extras --frozen +UV_PROJECT_ENVIRONMENT=.venv-posix uv run --no-sync pytest tests/ -q --tb=short -rs +``` + +Test temporary filesystem fixtures on Ubuntu's `/tmp` so they exercise Linux +capabilities. The mounted source checkout remains the canonical repository; +no development checkout is moved. Keep Linux results, source revision, exact +dependency versions and remaining skips separate from native Windows evidence. + +## Architecture and diagnostic impact + +### DEC-CORE-01 — Windows hook literal transport + +Disposition: **Deferred for the upstream contribution**. The following contract, +alternatives and limits describe the frozen fork's implementation and evidence. +The [frozen native Codex serializer](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/codex_hook_command.py) +and [frozen native hook execution tests](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) +remain available for a separate contribution. The restored upstream installer +does not implement this transport or earn AC01/AC05 from those historical passes. + +Codex's versioned native runner accepts one command string and may select Cmd or +PowerShell. A quoted executable token needs different invocation syntax in those +shells; Cmd additionally expands percent names inside quotes. A user-scope hook +therefore wraps the selected literal Graphify path in a UTF-16LE encoded +PowerShell payload, launched through the OS-owned Windows PowerShell executable. +The payload invokes the literal path and propagates failure/exit status. Paths +never become source in the outer consumer shell. POSIX uses its existing literal +quoting; project hooks retain the existing portable bare invocation. + +The serializer owns transport, installation owns JSON merging/publication, and +the native consumer owns shell execution. This uses an existing Windows OS shell; +Graphify extraction and runtime remain Python, with no corpus execution. An +unavailable shell or unsafe OS-executable token rejects publication before +reading/writing hooks. Bounded diagnostics omit private paths and exception +bodies. Status metadata supported by Codex identifies the owned hook for existing +installation/uninstall matching; the encoded payload is not decoded by that +matcher. Native shell dispatch, failure, repeated installation and retention +assertions characterize the contract. + +Encoded Windows commands are limited to 8,000 characters. The current Cmd +executable, `/C` and outer quotation must also fit within its documented +[8,191-character complete-command limit](https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/command-line-string-limitation). +This is conservative transport admission, not a claim that every such path exists +or can launch. An oversized command or failed OS-shell inspection rejects before +settings access, retains the existing hook file and rolling backup, and reports +an actionable reason without the private path or exception body. Shorter +installation paths and restored OS-shell access are the respective recovery steps. + +Bare user-scope commands, a launcher script and per-shell path quoting were +considered. Bare commands change the accepted executable identity policy; a +script introduces another persisted artifact and working-directory authority; +per-shell quoting cannot represent one string identically for both consumers. +The encoded transport keeps the selected executable contract and fails safely +when its OS dependency is unavailable. Actual Codex event delivery remains a +system boundary separate from the versioned runner argument-transport tests. + +The frozen fork's production corrections cover the Codex command's literal +executable invocation and the unavailable-CWD failure reason. Installation still +owns hook JSON publication and its existing diagnostics; fixtures own shell/path +transport. There is no new parser, logger, +cache owner, persistence mechanism or Qt diagnostic. Successful commands reach +the existing CLI; rejected arguments and filesystem failures retain existing +behavior. Test-only scope/provenance corrections need no cache invalidation. + +Unavailable-CWD recovery diagnostics distinguish `GRAPHIFY_REPO_ROOT` being +absent from an unsuccessful change to a supplied root. The failure remains +terminal before persistence; retry is safe after restoring an accessible root. +Messages name the failed stage and omit private path/exception bodies. This uses +the existing console error owner and introduces no Qt diagnostic code or logger. + +Codex command verification covers actual native Cmd and Windows PowerShell 5.1 +execution using the argument transports of the installed Codex 0.160.0 runner. +The fixture selects the real Graphify launcher, excludes a PATH decoy and checks +unsafe controls. Literal expansion-sensitive, bracket and Unicode paths are +included. This establishes the consumer boundary; it does not establish actual +Codex Desktop event delivery or all PowerShell versions. POSIX serialization is +retained, with actual POSIX dispatch assigned to the Linux profile. Full-source +and installed-artifact verification of INC-CORE-05 passes in the recorded native +profiles below; actual Linux dispatch remains unverified. +The inspected versioned source is the +[command executor](https://github.com/openai/codex/blob/rust-v0.160.0/codex-rs/hooks/src/engine/command_runner.rs) +and [session configuration](https://github.com/openai/codex/blob/rust-v0.160.0/codex-rs/core/src/session/mod.rs). + + + +## Historical restart checkpoint + +This section records `9efa7e4`/`c1c0e6a` before the later fork consumer correction +and native run. That later historical source evidence is in +[traceability](../tests/TRACEABILITY.md#current-native-source-and-artifact-evidence--2026-10-05). + +All dependency installation and focused test processes have finished. The full +source rerun is deferred until the WSL installation restart and initialization. +The native all-extras environment is compatible; the lock and original coverage +file remain unchanged. The final AST-only repository update completed with 22,274 nodes +and 50,115 edges using `graphify update . --no-cluster`, with no LLM invocation. +These raw graph counts are navigation output, not extraction correctness proof. + +Full Pyright with the explicit native environment reports 604 errors and zero +warnings. None belongs to the new focused test/helper files or corrected Terraform +test. The gate still fails; dependency installation and focused pytest/Ruff results +do not make it pass. The earlier implicit-interpreter run failed to resolve the +environment and is discarded as an invalid profile. Compare the explicit full +typing result against a matching baseline before claiming no regression. + +Resume in this order: verify WSL/Ubuntu availability, complete INC-CORE-05 literal +consumer handling, run focused cases in native and Linux profiles, run both full +source suites, review every skip, rebuild the installed artifact where production +changed, and update graph/evidence. Retain source and dependency fingerprints, +exact commands and failure identities. No full-source success is claimed at this +checkpoint. + +## Current native outcome + +This heading retains its historical anchor. The outcome belongs to the source +named below, preceding the integrated fork and the narrower contribution. + +Source `463ca3226c4aec4390a1e931fb1be1582059d1ca` has a full native Windows +result of 8,818 passes, no failures and 151 skips. Exact supplemental profiles +pass all 76 Node and seven compiler/wheel cases initially excluded for setup. +All profiles retain source, dependencies and coverage. There are 68 distinct +initial skips still unexecuted locally: 63 platform/capability cases, two +other-Python-version cases, one inapplicable historical bundle branch and two +FalkorDB service cases. Full typing retains 604 baseline errors with no added +diagnostics. [Exact commands and artifacts](../tests/TRACEABILITY.md#current-native-source-and-artifact-evidence--2026-10-05) +own the evidence; separate results are not combined into a fictitious full run. + +## Hosted runner proof procedure + +The hosted increment preserves the existing full Ubuntu source matrix and QML +wheel matrix, adds a focused Windows 3.12 generic compatibility lane, and retains +individual JUnit/skip and source/dependency evidence. A real Ubuntu runner +supplies the POSIX kernel that the local WSL profile cannot yet provide. The +Windows lane supplies Cmd/Windows PowerShell and Git Bash behavior; it does not +substitute for a Linux kernel or physical browser/device interaction. + +Each Ubuntu source job uses the official server-only image +`falkordb/falkordb-server:6.0.1@sha256:f4f60c62532b67f8651d0f4f50bbb4db7548717b502bd05abd80e668f2d974c6`. +The [tagged release](https://github.com/FalkorDB/FalkorDB/releases/tag/v6.0.1) +and [publisher's image workflow](https://github.com/FalkorDB/FalkorDB/blob/v6.0.1/.github/workflows/release-image.yml) +establish provenance; the digest fixes the selected image. This is not a +source-to-image build attestation. The +[tagged source license](https://github.com/FalkorDB/FalkorDB/blob/v6.0.1/LICENSE) +is Server Side Public License v1; Redis and base-image components retain their +own licenses. The service is an external CI oracle, with no server code or binary +added to Graphify's package. + +Setup starts one run-labeled container with only loopback port 6379 and no mounted +storage. It requires real `GRAPH.LIST` and `GRAPH.QUERY` readiness within 45 seconds, +deletes the probe graph and records image/module versions. The two existing +exporter/idempotency cases and all three reviewed-wheel cases must appear exactly +once in JUnit without skips, failures or errors. The wheel is built before source +pytest and its hash must remain unchanged. Existing installer fixtures temporarily +replace packaged assistant references and restore them during teardown; the +prebuilt wheel captures source before those substitutions. Each matrix job owns +its checkout, and post-test integrity requires restored tracked inputs and +unchanged dependencies. Post-test checks require no retained +test graph, and an independent cleanup step removes only the job-owned container +and confirms absence. Failed readiness or cleanup fails the job; a new attempt +starts with a new disposable service. No deployed-service, TLS, restart or +database-version matrix acceptance is implied. + +1. Validate the workflow configuration and embedded scripts locally; confirm + unchanged application source/lock and passing affected local evidence. Review + GitHub expression scopes with actionlint as well as YAML/native syntax: the + first hosted run rejected `runner.temp` in job-level `env`. Resolve evidence + directories from `RUNNER_TEMP` within the step. Review the source/configuration + change as one versioned increment. +2. Publish the explicit feature branch and create/update its draft PR against + the reviewed base when publication is authorized. The PR event owns full + validation; do not dispatch a duplicate feature run. Re-read the remote source + head and base before relying on an earlier record. +3. Read every applicable Ubuntu source, Windows compatibility and QML-wheel + job. Bind the workflow/event/run URL, source head, base and actual checkout + SHA: a PR checkout may be a synthetic integration commit. Inspect individual + acceptance testcases and skip reasons as well as the job conclusion. +4. Required tool/service readiness failures, missing required testcases, + unintended skips, changed inputs/dependencies and failed assertions retain a + failed result. Preserve the original pytest failure when retention/audit + steps execute. Diagnose and correct deterministic failures at the lowest + faithful boundary, push the new reviewed SHA and let normal PR validation + run; report infrastructure retries separately. +5. Verify the disposable testgraph/service cleanup record. Cancellation alone + does not establish cleanup; missing evidence remains outstanding. The hosted + runner lifecycle is an additional isolation boundary, not proof that a + particular cleanup step succeeded. + +Actual Codex event delivery, physical middle-button/browser behavior and omitted +runtime/device profiles retain their separate system procedures. Hosted pytest +does not repair the failed typing baseline or broaden generic-only Qt API support. +No merge or post-merge/protected proof is included in this validation increment. + +The native Windows selection also executes the discovered-source and physical +co-owner watch regressions from INC-QML-42/44. Owned NTFS directory junctions and +hardlinks exercise source ownership without requiring file-symlink elevation. +Actual Windows short-leaf spelling remains in the QML source selection; Linux +source CI executes the original real POSIX regressions. Capability exclusions +are retained individually and never counted as passing acceptance. + +## Deterministic Windows tool selection — INC-CORE-07 + +Status: **Verified for the recorded local and hosted native tool-selection profiles**. +Owner: CI Windows environment admission. Acceptance: REQ-CORE-004-AC02/AC03. +Dependency: INC-CORE-06. The hosted Windows image exposes multiple Node +applications. PowerShell's `Get-Command -CommandType Application` returns all +matches, so direct `.Source` projection produces an array instead of one +executable path. The first hosted compatibility attempt at `f95366d` rejects +before pytest; it establishes no native test pass or source-integrity result. + +The owning workflow selects the first application for Node and uv before +projecting its path. Node retains its exact required version check. The selected +Python, restricted process PATH, offline installation guards, test-exit retention +and evidence sequencing keep their existing owners. Multiple matches are a +successful admission case; missing tools or a wrong Node version reject before +collection. The prior partial dependency/import evidence remains retained, with +identity, JUnit and post-test integrity explicitly unavailable for that attempt. +These setup rejections do not mutate analyzed graph data. Process-local setup is +discarded with the runner; no product migration or new diagnostic code is needed. + +Ordinary regressions execute the actual workflow expressions with multiple real +applications, prove first-PATH executable identity and invocation, and cover a +missing required application and real incompatible executable. The historical +selector fails both duplicate-application cases; the corrected profile passes +all five cases with no skips. Native PowerShell 5.1 runs actual Node 24.19.0 and +uv binaries. The test-only absolute uv dependency is passed through +`GRAPHIFY_TEST_UV`, keeping installer commands off the consumer PATH. The workflow +measures 450 physical lines within its existing ceiling. The normal PR run supplies corrected hosted proof. +No Graphify runtime interface, Qt analysis epoch, cache/persistence ordering or +dependency contract changes; the compatibility procedure owns this infrastructure +design. Review the plan after the corrected run and add further work only for +another reproduced gap. + +## Native shell process identity — INC-CORE-08 + +Status: **Verified for the recorded local and hosted native shell-consumer profiles**. +Owner: shared shell test transport and native CI admission. Acceptance: +REQ-CORE-004-AC02/AC03. Dependency: INC-CORE-07. + +Hosted source `38bf8cc` reaches native pytest with 783 passes, 33 failures and +20 skips. All failures belong to Bash allowlist/payload and generated skill input +security cases. Git Bash is present and the explicit preflight passes, but Python +subprocesses launch the Windows WSL stub when given the bare `bash` name. A real +native reproduction resolves Git Bash with PATH lookup, then obtains a WSL +installation notice from bare process launch; launching that absolute Git Bash +file succeeds. Availability lookup alone does not bind process identity. + +Acceptance matrix: select and execute the actual admitted absolute shell; +preserve literal paths, raw payloads, hostile-input rejection and vulnerable +positive controls; reject unavailable shell selection without collection skips; +prove a competing native/CWD executable cannot replace the selected shell. +MSYS path conversion uses that same installation's mapper. Native CI preflight +must exercise the ordinary Python consumer boundary, and retain selected shell +identity with version, revision, JUnit and integrity evidence. POSIX lookup and +production discovery defaults remain unchanged. + +This is test/environment transport ownership. It changes no Graphify runtime, +extraction epoch, dependency, cache or product-persistence contract. Missing or +failed consumers reject setup; process-local admission cannot alter analyzed +data. Existing job failure preservation and cleanup own recovery. No new product +diagnostic code or infrastructure rerun is required. Exit with the unchanged +failed cases passing, executable identity/rejection regressions and corrected +normal PR evidence; review new outcomes before allocating further work. + +Current shell selection resolves only qualified candidates in configured PATH +order and launches the absolute selected file. Native implicit CWD/system search +cannot replace it; an explicit CWD PATH entry remains eligible. All original +literal/hostile-input/control assertions are preserved. Eighteen helper cases +pass, including three real consumer failures before correction. The combined +shell/hook/skillgen source selection passes 161 with eight established profile +exclusions. Revised preflight proves actual Python and Node identities through +both selected shells, and retains `shell-consumers.json`. Exact commands, counts, +skip identities and limits belong to +[traceability](../tests/TRACEABILITY.md#native-shell-launch-identity--inc-core-08). + +## Native consumer evidence retention — INC-CORE-09 + +Status: **Verified native artifact retention at source 10cb15a**. +Owner: native compatibility artifact publication. Acceptance: REQ-CORE-004-AC03. +Dependency: INC-CORE-08. Source `f699b0f` passes all 836 native cases with 21 +explicit exclusions, but its uploaded artifact omits `shell-consumers.json`. +Preflight assertions pass and source/dependency/JUnit evidence is retained; exact +consumer identity JSON is unavailable after runner cleanup. That passing test +result does not satisfy the missing evidence contract. + +Add the already generated report to the existing artifact inclusion list. Keep +its producer, validation ordering, tests, permissions, event and failure behavior +unchanged. No runtime, corpus, schema, dependency or graph-persistence change is +introduced. A missing report remains a specific system-proof gap; it does not +justify another privileged workflow, fabricated executable identity or skipped +test. The next normal PR event owns correction proof after the intended current +runs reach terminal results. + +Both intended runs for `f699b0f` are terminal. All twelve optional Qt/QML lanes +pass; all assigned Linux/service/wheel cases pass individually on all four +interpreters. Three complete Linux suites pass; Python 3.10 exposes a separate +cleanup-fault fixture seam because its `Path.unlink` bypasses a later `os.unlink` +patch. Preserve the production cleanup/diagnostic behavior and correct that +fixture before the next normal PR validation. The exact checkpoint and limits +are recorded in [traceability](../tests/TRACEABILITY.md#hosted-source-and-optional-wheel-checkpoint--f699b0f). + +Acceptance procedure: obtain the native artifact ID/digest through GitHub's API; +download that exact archive and compare SHA-256 to its API digest; require the +report alongside identity, JUnit, pytest exit and integrity evidence. Parse its +bounded two-shell record as data and compare each shell's admitted executable, +Python and Node against the owning workflow/profile identity. Confirm the same +source/base/actual checkout and passing preflight, unchanged dependencies/source +and terminal pytest result. Missing/inconsistent fields or archive integrity +failure leave AC03 unverified. This external artifact boundary is checked through +the real service; a literal workflow assertion cannot prove upload completion. +No new secret or disposable service is introduced; existing runner/artifact +retention and cleanup own recovery. Exit with exact retained-report proof and +review all remaining jobs for another reproduced gap. + +The corrected normal PR native job passes 836 tests with 21 explicit exclusions. +Artifact `11323730916` includes the real two-shell report; downloaded archive +SHA-256 `98e8645f9fd208b6502c62ea687fa83c81b48c6e190d9698ce62b85ffe1977b4` +matches GitHub's API digest. Both admitted Git Bash/sh executables report the +selected checkout interpreter and Node 24.19.0, agreeing with the actual same-file +preflight, source import and job identity. JUnit, pytest exit, 197 unchanged +dependencies and unchanged checkout/tracked inputs accompany the report. +All current Linux and optional-wheel jobs also pass; exact profiles and remaining +contribution/system limits belong to the verified checkpoint. No additional +compatibility increment is allocated by this exit review. + +## Focused legacy ownership and size constraints + +The measurements below belong to the recorded complete-fork compatibility +checkpoints, including deferred native-hook work. They are not measurements or +growth allowances for the narrowed contribution. At those checkpoints, new +helpers/tests remain below 300 physical lines. Existing oversized files keep +their established owners and receive only the focused correction/profile changes: + +| File | Before | Checkpoint ceiling | Owner and extraction exit | +| --- | --- | --- | --- | +| `graphify/install.py` | 2559 | 2570 | Historical install maintainer; Codex serialization belonged to the [frozen native Codex serializer](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/codex_hook_command.py); this independent transport is deferred and the contribution restores upstream installer ownership | +| `graphify/watch.py` | 2532 | 2550 | Watch maintainer; existing documented extraction path and persistence ordering retained | +| `tests/test_hooks.py` | 1590 | 1600 | Hook test owner; new shell identity transport is already a focused helper, unrelated hook cases stay in place | +| `tests/test_install.py` | 1617 | 1640 | Install test owner; the contribution retains the portable Hermes destination assertion and restores baseline Codex expectations; native transport cases remain deferred frozen evidence | +| `tests/test_install_references.py` | 548 | 553 | Install-reference test owner; separate platform destination characterization before extracting responsibilities | +| `tests/test_install_roundtrip.py` | 318 | 323 | Install-roundtrip test owner; keep installed bundle identity in one roundtrip boundary | +| `tests/test_skill_auto_refresh.py` | 527 | 541 | Refresh test owner; split platform adaptation from refresh lifecycle only in a separate characterized move | +| `tests/test_watch.py` | 5100 | 5104 | Watch test owner; new portable failure cases live in the focused filesystem-profile module | +| `.github/workflows/ci.yml` | 106 | 470 | CI maintainer; full Ubuntu and focused Windows execution/evidence sequencing remain one cohesive workflow; CORE-08 adds actual Python shell/consumer admission after the earlier 450-line checkpoint; extract a shared evidence helper when another caller needs it or further meaningful growth exceeds this ceiling | + +The ceilings authorize this bounded change, not routine growth. No mechanical +extractor move, broad refactor or generated artifact edit is mixed into the fixes. + +The CI cohesion exception retains tool/service admission, source/dependency +identity, test exit preservation and cleanup ordering together. Splitting these +blocks solely to meet 300 lines would obscure their execution contract. The +measured workflow size and local syntax checks belong to the increment's +traceability record; 470 is a bounded ceiling, not permission for routine growth. + +The current branch remains the integration checkout. Delegated owners have +disjoint installer, shell-fixture, filesystem-fixture and Terraform-test scopes; +the integration owner handles environment, shared documentation and Git. Incoming +upstream and overlapping hook/destination proposals are reviewed before choosing +the correction; publication and upstream integration are separate delivery work. + +## Native external-call subprocess admission — INC-CORE-10 + +Status: **Corrected; recorded native and four Linux profiles verified at 8b6c9d2**. Owner: upstream +test maintainer. Current-upstream integration exposes the existing +`test_python_external_calls_survive_real_incremental_context` subprocess on +native Windows. The original fixture forwarded HOME but omitted USERPROFILE and the native +home/system fields; `Path.home()` fails before extraction with “Could not +determine home directory.” This is a test-environment admission failure, not +evidence of incorrect Python/Qt resolution. + +The fixture retains its isolated configuration and forwards the required native +home/system fields from the already sandboxed environment. No provider secrets, +global corpus or installation fallback are added. All source, external-call and +incremental assertions remain unchanged. Existing subprocess/API owners and +diagnostics need no production change. + +REQ-CORE-004-AC02 assigns the real corrected native child-process case, alongside +the four Linux profiles. AC01/AC03/AC04 retain exact normal-PR identity, individual +case evidence and source/dependency retention. Required exit: original failure +evidence, corrected case and neighboring context tests, proportionate lint/type +checks, native CI selection and terminal hosted proof. Review the integration +plan for further reproduced gaps before completing this increment. + +Local proof: the exact corrected native subprocess case and neighboring context +tests pass 81/81. The scoped upstream matrix passes 601 with eleven explicit +capability/platform skips. All ten prior extraction assertions remain structurally +unchanged; only the native home/system allowlist and explanatory comment differ. +Ruff passes. The existing oversized fixture remains under the documented +5460-line ceiling; no production or dependency change is introduced by CORE-10. + +The [verified integrated checkpoint](../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2) +owns current source/base/checkout, individual subprocess/native outcomes, +uploaded shell identities, integrity and cleanup. Earlier workflow-size values +describe their original compatibility checkpoints; current-upstream integration +measures 485 lines with its 510-line ceiling and extraction exit in +[design](qt-qml/DESIGN.md#current-upstream-integration--inc-qml-49--d26). +No additional shared increment is allocated by the completed integration review. diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md new file mode 100644 index 0000000000..100c70a1a5 --- /dev/null +++ b/docs/REQUIREMENTS.md @@ -0,0 +1,707 @@ +# Graphify requirements + +## Upstream contribution scope + +The [upstream contribution](qt-qml/UPSTREAM_CONTRIBUTION.md) retains Qt/QML +analysis and its shared integrity/consumer prerequisites. REQ-QML-019/021 and +REQ-CORE-001-AC01/AC05 are deferred independent features in this contribution. +Their stable contracts and complete-fork evidence remain historical; they are +not completed or verified by this source. The source-count projection portion +of REQ-QML-019-AC02 remains a REQ-QML-020 dependency; the broader viewer criterion +remains deferred. Other evidence is bound to its +recorded revision and must be refreshed for the scoped contribution head. + +This is the canonical product requirements document. Entries cover the +Qt/QML support extension and shared assistant/analysis compatibility. Add or update product requirements here with the +behavioural change, preserving established identifiers and acceptance traceability. +Identifiers use separate `REQ-QML-` and `INC-QML-` namespaces under the +[identifier and legacy-alias contract](qt-qml/IDENTIFIERS.md). +Shared compatibility requirements use `REQ-CORE-NNN` and criteria +`REQ-CORE-NNN-ACNN`; delivery uses `INC-CORE-NN`. These independent counters +preserve every Qt/QML identity. The [compatibility plan](COMPATIBILITY.md) +defines their scope and evidence boundaries. + +The Qt/QML catalog contains twenty-one requirements and eighty-five stable acceptance +criteria. INC-QML-00–07 evidence describes the original bounded Qt 6/QML profile; +later corrections and adoption extend that profile without renumbering it. +Current source, update, consumer and installed evidence is assigned individually +in [tests/TRACEABILITY.md](../tests/TRACEABILITY.md). + +INC-QML-11, INC-QML-15 and INC-QML-08a/b/c are implemented and locally complete for +the documented Windows x64/Python 3.12 profile. Bounded inherited signals, exact +constructor identity, qmake paths, C++ header admission, declared providers and +child-service subscriptions have source and reviewed installed-wheel evidence. +INC-QML-28–45 correct the additional reproduced producer, consumer, transport, +publication, path-identity and diagnostic failures. The integrated checkpoint +uses AST cache schema 13 and Qt policy 22; its +[revision-bound hosted proof](../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2) +records current source, native and isolated-install profiles. + +All seven REQ-QML-018 criteria have passing local acceptance assignments. The +requirement is **Implemented; locally verified for the bounded Windows profile**, +not Verified across the full declared platform matrix. The earlier adoption +checkpoint retains 59 pre-existing failures; the later native full-source run +records 8,818 passes and zero failures. Subsequent alias and diagnostic profiles +are recorded separately; full typing retains baseline errors. Unexecuted hosted lanes, native browser/device behavior, +live database delivery and runtime Qt effects remain explicit gaps. See the +[final adoption evidence](qt-qml/VALIDATION.md#final-adoption-delivery), +[implementation limits](qt-qml/IMPLEMENTATION.md#final-adoption-delivery), +[export contracts](qt-qml/EXPORT_MATRIX.md) and +[platform matrix](qt-qml/PLATFORM_MATRIX.md). + +## Requirement catalog + +The [follow-up audit](qt-qml/FOLLOWUP_AUDIT.md) reproduced wrong receiver, +child, engine-provider and native-alias targets at its baseline. INC-QML-17–20 +correct those bounded forms and the two omitted literal loader routes. Source, +manual/watch lifecycle and reviewed installed-wheel evidence is recorded per +increment in [validation](qt-qml/VALIDATION.md) and acceptance traceability. +Existing acceptance IDs and numeric identities are unchanged. + +The expanded criteria remain **Partially verified** across their full adoption +profile. INC-QML-21–27 implement qualified native identity, static API/loader SDK +authority, retained Windows writes, coordinated product publication, orphan +reference cleanup and native property notification handlers. Their focused source, reviewed installed-artifact and contribution-gate +results are assigned in validation and traceability. The correction cases have +local passing evidence; skipped/system and wider profile cases remain unverified. Provider adoption, inherited endpoints and overload identity +now have the bounded local source and installed evidence recorded for INC-QML-08/11/15. Original fixture passes establish their recorded profiles, +not every SDK API or runtime effect. Baseline test/type failures remain explicit. + +Unsupported parenting, type, URL, overload or lifetime evidence retains an +unresolved source site. Matching text or successful publication alone does not +establish a target. The [correction acceptance matrix](qt-qml/DESIGN.md#semantic-correction-acceptance-matrix-inc-qml-1720) +defines success, rejection and lifecycle obligations; the +[API mechanism matrix](qt-qml/QT_API_COVERAGE.md) distinguishes supported forms, +conservative exclusions, generic extraction and remaining omissions. + +Each criterion belongs to the requirement named in its ID. Criteria without +executed evidence in IMPLEMENTATION.md remain **Not executed**. Verification owners +and coverage gaps are assigned in [tests/TRACEABILITY.md](../tests/TRACEABILITY.md). +Traceability labels actual executed tests and retains explicit cases for later increments. + +Each acceptance ID has a planned completion increment in +[tests/TRACEABILITY.md](../tests/TRACEABILITY.md), with dependencies and reviewable +work packages in the [increment plan](qt-qml/PLAN.md). Earlier partial evidence does +not close a criterion whose metadata, incremental or consumer cases remain pending. + +The agreed initial profile is Qt 6 with both CMake and qmake. INC-QML-00 records the +exact syntax, version, platform and parser matrix used by the criteria. Cases +outside that matrix must be explicitly Unsupported or Deferred, with a reason; +removing a promised case requires a documented requirement change. A requirement +becomes Verified only when each applicable criterion has cited passing evidence. + + + +### REQ-QML-001 — Parser availability and compatibility + +The declared parser backend installs on supported Python/OS targets, parses the supported syntax without network access at analysis time, and reports unavailable or incompatible parser support explicitly. Unsupported syntax is distinguished from an empty valid file. + +**Acceptance Criteria** + +1 - A built Graphify wheel with the selected QML extra installs in each declared Python/OS lane and exposes a usable parser through Graphify's production entry point; record parser/API/grammar versions and licenses. (`REQ-QML-001-AC01`) + +2 - With network access disabled and no Qt SDK, every supported-syntax fixture yields its hand-checked declarations and spans; repeated runs produce the same normalized result. (`REQ-QML-001-AC02`) + +3 - Without the extra, or with an incompatible parser, scanning a QML file produces a file-level unavailable-parser diagnostic and does not cache an empty result as successful AST extraction. (`REQ-QML-001-AC03`) + +4 - Empty valid, malformed, and unsupported-syntax fixtures produce distinguishable completion/coverage diagnostics; malformed input never passes as fully analyzed. (`REQ-QML-001-AC04`) + + + +### REQ-QML-002 — Corpus discovery + +Full detection and update agree on inclusion of `.qml`, `.ui.qml`, and supported named metadata. Existing ignore, root, symlink, and size boundaries apply. `qmldir` is detected by exact filename. Unrelated extensionless files remain excluded. + +**Acceptance Criteria** + +1 - Full detection, code-only collection, direct supported-file extraction, manual update and watch admit `.qml` and `.ui.qml`; each path selects the same intended extractor. (`REQ-QML-002-AC01`) + +2 - Each delivered metadata format, including exact-name `qmldir` and `CMakeLists.txt`, is admitted consistently by its applicable entry points; unrelated extensionless files remain excluded. (`REQ-QML-002-AC02`) + +3 - Ignored, out-of-root, disallowed symlink and over-limit fixtures remain excluded from extraction and metadata traversal under the existing corpus policy. (`REQ-QML-002-AC03`) + +4 - Windows/POSIX separators, spaces and Unicode filenames give equivalent normalized corpus membership; existing non-QML classification regression cases still pass. (`REQ-QML-002-AC04`) + + + +### REQ-QML-003 — Source-backed declarations and identity + +Components, object instances, properties, signals, and functions produce source-backed nodes with correct locations and deterministic identities. Duplicate `id` values in different component scopes do not merge. Comments, strings, and grouped properties do not create false object declarations. + +**Acceptance Criteria** + +1 - A fixture with nested objects, properties, signals and functions yields exactly the expected declarations, ownership links, original names and source locations. (`REQ-QML-003-AC01`) + +2 - Equal `id`/member names in different component scopes, duplicate basenames in different directories, and `Panel.qml` beside `Panel.cpp` produce distinct identities. (`REQ-QML-003-AC02`) + +3 - Comments, strings containing braces/type names, and grouped property blocks create no false component/object declarations; valid object declarations remain present. (`REQ-QML-003-AC03`) + +4 - Unchanged input preserves normalized IDs across sequential/parallel, warm/cold cache and relocated-root runs; a line inserted before a named declaration changes its span without changing its identity. (`REQ-QML-003-AC04`) + + + +### REQ-QML-004 — Imports and module resolution + +Directory and URI imports, aliases, versions, and `qmldir` declarations resolve only within documented import paths and module visibility. Duplicate type names in different modules cannot bind by a global name guess. Unknown, ambiguous, or unavailable modules remain unresolved. + +**Acceptance Criteria** + +1 - Directory, URI, aliased and supported versioned imports resolve to exactly the visible providers in a hand-checked multi-module fixture, preserving import evidence. (`REQ-QML-004-AC01`) + +2 - Two modules exporting the same type name do not cross-bind; aliases, selected import versions and `qmldir` visibility determine the endpoint. (`REQ-QML-004-AC02`) + +3 - Missing modules, incompatible versions and duplicate eligible providers retain explicit unresolved/ambiguous reasons and produce no arbitrarily resolved edge. (`REQ-QML-004-AC03`) + +4 - Resolution uses only declared corpus/import roots; an ignored or out-of-root provider and a remote import do not trigger source expansion or network access. (`REQ-QML-004-AC04`) + + + +### REQ-QML-005 — Component and member scopes + +Component-local `id` scope, inline components, singleton declarations, inherited members, and external types retain their documented boundaries. Same-name members in separate scopes remain distinct. Only supported, source-backed visibility permits a resolved link. + +**Acceptance Criteria** + +1 - References to component-local `id` values resolve inside the correct component; identical IDs in another file or inline component are not visible accidentally. (`REQ-QML-005-AC01`) + +2 - Inline-component shadowing, singleton use and supported inherited-member cases match hand-checked visibility expectations without merging equal names. (`REQ-QML-005-AC02`) + +3 - Private/internal, unavailable external, and unsupported dynamic members remain unresolved unless explicit supported metadata establishes their visibility. (`REQ-QML-005-AC03`) + +4 - Imported type names, object instances and members have separate identities and lookup roles; a same-label type or member from an unrelated scope cannot satisfy a reference. (`REQ-QML-005-AC04`) + + + +### REQ-QML-006 — Bindings and property aliases + +Supported property bindings and property aliases produce directional dependency links to visible targets with expression evidence. Evaluation, getters, and side effects never run during analysis. Dynamic or unsupported expressions retain an explicit unresolved status. + +**Acceptance Criteria** + +1 - Supported property reads and aliases create links to the expected visible source members, with the documented dependency direction and originating expression span. (`REQ-QML-006-AC01`) + +2 - Nested qualified aliases and binding expressions honor component/object scope; missing or ambiguous targets produce a coverage reason and no guessed endpoint. (`REQ-QML-006-AC02`) + +3 - A fixture containing an expression with observable side effects is analyzed without executing the expression, invoking a getter or loading its runtime dependencies. (`REQ-QML-006-AC03`) + +4 - Distinct reads/bindings involving the same endpoints survive extraction, graph construction and serialization according to the accepted nonlossy projection contract. (`REQ-QML-006-AC04`) + + + +### REQ-QML-007 — JavaScript, handlers and signals + +Embedded functions, handlers, `Connections`, and imported JavaScript participate in scoped call/reference and signal relationships. Lexical shadowing, import aliases, `.pragma library`, and shared JS between QML/other callers are covered. Dynamic dispatch does not become a definite call. + +**Acceptance Criteria** + +1 - Embedded functions and handlers resolve statically visible calls/references with original QML source locations; lexical shadowing prevents links to hidden same-name functions. (`REQ-QML-007-AC01`) + +2 - Imported `.js`/`.mjs` helpers, aliases and supported `.pragma library` directives resolve without changing ordinary JS behavior or executing script code. (`REQ-QML-007-AC02`) + +3 - Supported `Connections` and handler fixtures link the expected target signal and handler, distinguish declarations from subscriptions, and preserve evidence/direction. A native `onChanged` subscription uses the property's actual accepted +NOTIFY signal, including a differently named or shared signal. Explicit function +and arrow handlers bind their declared formal parameters; only legacy block +handlers receive implicit signal parameters. A missing, invalid, CONSTANT, +ambiguous or unexposed notifier creates no guessed subscription. (`REQ-QML-007-AC03`) + +4 - Runtime-selected targets and dynamic calls remain explicitly unresolved; shared JS used by QML and non-QML callers does not gain spurious cross-language edges. (`REQ-QML-007-AC04`) + + + +### REQ-QML-008 — Qt/C++ exposure bridge + +Supported `QML_ELEMENT`/named/singleton and literal `qmlRegister*` registrations link QML-visible names to the correct C++ class and module. `Q_PROPERTY`, invokable methods, signals, and notify relationships retain source evidence. Matching labels or a `Q_OBJECT` macro alone cannot establish exposure. + +**Acceptance Criteria** + +1 - Valid `QML_ELEMENT`, identifier-form `QML_NAMED_ELEMENT(Backend)`, singleton and supported literal `qmlRegister*` fixtures map visible QML names to the correct C++ declarations and module evidence. (`REQ-QML-008-AC01`) + +2 - `Q_PROPERTY`, invokable methods, signals and `NOTIFY` references retain correct source spans and members; header/implementation pairs reuse existing canonical class/method identities. Distinct qualified classes sharing a basename within one source file retain separate canonical class identities. Merged declarations retain exact accepted definition-file/location and class-ownership evidence when mapping native Qt overlays. Forward declarations retain their facts and IDs without competing with a unique complete class definition for ownership. Fresh and accepted-context representations of the same complete body retain exact source file/span and count as one body without mutating borrowed inputs; distinct complete definitions remain ambiguous. A member or function-local class occurrence with a uniquely accepted canonical callable retains a source-site containment link even when its class has no accepted Qt definition; this does not establish a native class, QObject role or QML exposure. Missing or conflicting callable evidence cannot create that link or an arbitrary class owner. An observed Qt occurrence without a callable owner retains containment by its uniquely accepted in-corpus source file, using the actual canonical file ID and original occurrence span. Missing, foreign, ambiguous or incorrectly typed file evidence creates no link; file containment leaves callable/class/target identities and unresolved status unchanged. Native property notification references retain the canonical accepted signal +identity and provider ownership through serialization; property handler spelling +does not create a synthetic native signal. Supported constructors with bounded +builtin, self-class or evidenced QObject parameter types retain distinct signature +identities independent of neighboring overloads, parameter names and defaults. +Exact declaration/definition spans identify each accepted owner; conflicting +signatures, types or bodies cannot lend native authority. (`REQ-QML-008-AC02`) + +3 - Duplicate registrations, unsupported macro wrappers and ambiguous overloads retain reasons rather than arbitrary bridges; `Q_OBJECT`, inheritance or matching labels alone create no exposure link. (`REQ-QML-008-AC03`) + +4 - Qt macro text inside comments/strings has no semantic effect, source offsets remain correct after parser recovery, and existing C++ normalization/test-macro/cross-language guard regressions pass. (`REQ-QML-008-AC04`) + + + +### REQ-QML-009 — Qt project and resource metadata + +Supported literal CMake, qmake, `qmldir`, `.qmltypes`, and `.qrc` declarations describe module membership, type metadata, and resource aliases. Project files, plugins, and JavaScript never execute. Variable expansion or generated inputs outside the supported subset are reported as incomplete. + +**Acceptance Criteria** + +1 - Qt 6 CMake and qmake fixtures yield the expected literal module URI/version, sources, imports and resources; macro-based C++ exposure gets its module context from this evidence. (`REQ-QML-009-AC01`) + +2 - Supported `qmldir` and `.qmltypes` fixtures retain exports, flags, members and provenance; conflicting source/generated declarations retain both origins and a conflict diagnostic. (`REQ-QML-009-AC02`) + +3 - `.qrc` prefixes/aliases resolve supported resource URLs to accepted source files; traversal, out-of-root references and XML external-entity payloads cannot read host files or expand the corpus. (`REQ-QML-009-AC03`) + +4 - CMake/qmake conditions or expressions outside the literal subset produce an incomplete/unsupported reason; analysis never runs a build tool, plugin, `moc` or QML engine. (`REQ-QML-009-AC04`) + + + +### REQ-QML-010 — Deterministic graph and provenance + +Repeated analysis of identical input has stable IDs and normalized output. Serialized graphs retain valid endpoints, direction, relation context, and provenance. Multiple distinct relationships between the same endpoints cannot silently disappear. Inferred resolution stays distinct from extracted syntax. + +**Acceptance Criteria** + +1 - Identical inputs yield equal canonical node/edge/fact output across process count, filesystem order and cache state; case-distinct or normalization-colliding names remain distinguishable. (`REQ-QML-010-AC01`) + +2 - Every emitted edge has valid endpoints and every source-backed fact retains an accepted root-relative path and correct source span after ID remapping and JSON reload. (`REQ-QML-010-AC02`) + +3 - A fixture with different relations on the same endpoint pair round-trips all promised facts through build/export/reload; no relation disappears through simple-graph overwrite. (`REQ-QML-010-AC03`) + +4 - Extracted syntax, inferred resolution and ambiguity retain their accepted confidence/evidence distinction; sourceless stubs cannot overwrite a source-backed definition's provenance. (`REQ-QML-010-AC04`) + + + +### REQ-QML-011 — Incremental correctness + +Cold rebuild, warm cache, manual update, and watch agree on normalized Qt/QML output. File edits, renames, deletion, import-path changes, parser/config changes, and metadata-only or C++-only changes invalidate affected resolution. Unchanged QML files can acquire or lose links after their providers change. + +**Acceptance Criteria** + +1 - Cold build, warm build, manual update and watch produce equal normalized Qt/QML graphs for the supported fixture corpus after a normal QML edit. Manual `graphify update --follow-symlinks` admits the same contained directory-link discovery profile as the existing watch rebuild option; omission preserves normal discovery defaults, and ignored or external targets remain excluded. In mixed and generic corpora, a watch notification for one already admitted nonsemantic regular-file source refreshes every admitted nonsemantic contained owner of the same physical bytes, including supported directory aliases and hardlinks, while preserving distinct walked IDs and unrelated facts. Semantic-backed siblings retain their existing tier; identity grouping cannot discover or admit another source. (`REQ-QML-011-AC01`) + +2 - Metadata-only and C++-registration-only edits, provider deletion/rename and duplicate-provider introduction update links in unchanged QML and remove stale derived edges. (`REQ-QML-011-AC02`) + +3 - Parser/grammar/config/import-root/ignore-policy changes invalidate the affected cached facts or resolution; stale output cannot be accepted solely because file content hashes match. Shared physical-source edits, rename and deletion remove stale facts under each remaining admitted owner; one notification cannot preserve an obsolete sibling owner. An absent source before discovery remains an intentional deletion, while failed identity lookup after admission rejects partial work. (`REQ-QML-011-AC03`) + +4 - Repeated no-change updates are idempotent and preserve unrelated source facts; the final incremental graph equals a clean rebuild after every delivered mutation case. Removing or restoring source cannot leave stale synthetic reference nodes without an accepted remaining owner, including generic C++ include stubs. Still-referenced external nodes and source-backed definitions remain intact. (`REQ-QML-011-AC04`) + + + +### REQ-QML-012 — Failure handling and persistence + +Malformed input, missing parser, partial extraction, and failed resolution cannot replace a valid persisted graph/cache with a misleading empty or incomplete result. Diagnostics identify unsupported or failed stages. Existing zero-node, shrink, and provenance protections remain effective. + +**Acceptance Criteria** + +1 - Missing parser, malformed source and a forced extractor/resolver failure produce bounded, stage-specific diagnostics and an explicit incomplete status. Persistent input-identity failure at the Qt/QML resolution boundary cannot escape diagnostic construction or disclose the backend exception body; every affected Qt/QML source receives `QML_RESOLUTION_FAILED`, with safe relative context where available and explicit unavailable context otherwise. Failed physical co-owner selection rejects with `WATCH_SOURCE_IDENTITY_FAILED`; failed native file-spelling admission rejects with `SOURCE_INPUT_IDENTITY_FAILED` before worker/cache work. Both pre-extraction rejections use bounded quoted relative context or an explicit empty field, omit backend bodies and identify repair/retry guidance, without publishing fresh source facts. (`REQ-QML-012-AC01`) + +2 - Starting from a valid persisted graph, those failures cannot replace it with a misleading empty/incomplete graph or poison a successful cache entry under existing write protections. Required graph, manifest, analysis-root and Qt-state publication must complete before reporting update success; a failed publication retains prior accepted products and reports failure, with explicit recovery on rollback failure. A second failure cleaning partial setup reports `GRAPH_PUBLICATION_CLEANUP`, preserves accepted bytes and retained owned recovery copies, and omits private paths/backend bodies from the public message. (`REQ-QML-012-AC02`) + +3 - Intentional deletion is distinguished from extraction failure; zero-node/shrink guards, provenance preference and documented destructive-update controls preserve their existing behavior. (`REQ-QML-012-AC03`) + +4 - Oversized/deep/hostile source and metadata fixtures terminate under documented bounds, disclose no credentials or machine-private paths, and execute no analyzed instructions. (`REQ-QML-012-AC04`) + +Status: **Verified for the recorded bounded source, installed and hosted diagnostic/retention profiles**. +INC-QML-48 corrects the Python 3.10 cleanup-fault fixture's injection boundary; +its exact regression passes all four hosted Linux interpreters. +INC-QML-41 adds persistent-identity failure coverage +for AC01/AC02: complete failure records and safe context, exact prior-product +retention, repaired retry and byte-stable repeat. A diagnostic label is at most +160 characters and excludes controls/line separators; unavailable or unsafe +context is the empty `source_file` string and grants no lookup authority. +INC-QML-44/45 add distinct pre-extraction watch/native admission rejection codes, +actual cache/product retention, repaired retry and bounded lexical-context tests. +The combined installed profile passes all 59 new cases without skips; eleven +older file-symlink cases retain actual capability exclusions. The +[verified hosted checkpoint](../tests/TRACEABILITY.md#verified-hosted-runner-checkpoint--10cb15a) +records separate source/platform outcomes and their exclusions. +Exact assignments and capability exclusions are in +[traceability](../tests/TRACEABILITY.md#failure-safe-join-diagnostics--inc-qml-41). + + + +### REQ-QML-013 — User-facing graph consumers + +Query, explain, path, affected, JSON/HTML and other supported exports, and optional MCP consumers preserve the accepted Qt/QML nodes, relation context, locations, and direction. Scoped results do not invent unavailable framework internals. + +**Acceptance Criteria** + +1 - Query, explain and path fixtures return the expected component/module/member route with original locations and confidence; scoped answers do not connect unrelated same-name providers. (`REQ-QML-013-AC01`) + +2 - Affected traversal follows the documented dependency direction for property/signal changes and includes the expected handlers/components, including accepted Qt relation contexts. (`REQ-QML-013-AC02`) + +3 - JSON, HTML and each advertised export retain the supported Qt/QML facts and evidence after reload; omission by a consumer is explicit rather than presented as full support. (`REQ-QML-013-AC03`) + +4 - With the MCP extra installed, production MCP queries agree with equivalent CLI fixtures; searchable Qt metadata is deliberately projected/indexed rather than assumed discoverable. (`REQ-QML-013-AC04`) + + + +### REQ-QML-014 — Platform and compatibility evidence + +Supported Python/OS targets have install and behavior evidence. Mixed C++/JS and other affected language regressions stay green. Windows paths, Unicode, imports with the same stem, and optional-parser absence are explicit cases. Skips and baseline failures remain visible. + +**Acceptance Criteria** + +1 - Every advertised Python/OS lane has a successful parser install and offline production-extraction result against the agreed corpus, with exact versions and extras recorded. (`REQ-QML-014-AC01`) + +2 - Windows path, Unicode, same-stem, relocated-root and optional-parser-absence fixtures pass the same observable contracts as supported POSIX lanes. (`REQ-QML-014-AC02`) + +3 - Relevant generic C++/JS, discovery, graph, cache, update and consumer regressions introduce no unexplained new failures relative to the recorded baseline. (`REQ-QML-014-AC03`) + +4 - Skips, baseline portability defects, unavailable optional dependencies and type-check failures remain separately reported; a skipped or unrun lane cannot count as passing support evidence. (`REQ-QML-014-AC04`) + + + +### REQ-QML-015 — Documentation and upstream delivery + +Public support documentation accurately lists implemented syntax, resolution limits, and optional dependencies. Generated assistant instructions are updated through their source fragments. Each increment has a reviewable PR, matching tests, and verified upstream-base compatibility before release. + +**Acceptance Criteria** + +1 - Each implementation PR updates requirement/acceptance status, actual test mapping and the public support/limit matrix to match delivered behavior, with no planned case described as shipped. (`REQ-QML-015-AC01`) + +2 - Changed assistant instructions originate in authoritative skillgen fragments; generated artifacts and applicable schema/round-trip checks pass without hand edits. (`REQ-QML-015-AC02`) + +3 - Each delivery PR records a reviewed upstream base/head, dependency/license provenance, exact executed checks and the PR #1748 reuse/attribution decision; duplicate feature work is avoided. (`REQ-QML-015-AC03`) + +4 - The release gate has reviewed evidence for every promised acceptance ID, explains any explicitly deferred criteria, and contains no private project references, credentials, internal endpoints or machine-specific paths. (`REQ-QML-015-AC04`) + + + +### REQ-QML-016 — Qt C++ signals, slots and connections + +Qt C++ signals, slots, emissions and signal connections retain their meta-object semantics. Supported member-pointer, functor/lambda, explicit-overload and legacy `SIGNAL`/`SLOT` forms link source-backed endpoints with connection evidence and declared type/flags; signal delivery is distinct from an ordinary direct call. + +**Acceptance Criteria** + +1 - Fixtures using `signals`, `Q_SIGNALS`, `Q_SIGNAL`, slot access sections, `Q_SLOTS` and `Q_SLOT` retain member signatures/kinds and source spans; `emit`/`Q_EMIT` link the originating code to the declared signal without inventing immediate receiver calls. Explicit emission sites remain observable and unresolved when their declaration is missing or removed. A forward declaration does not obscure the unique complete class or exact canonical header/implementation method that owns the emission. Supported source-visible inherited signals retain their declaring endpoint through multi-level base chains; incomplete or ambiguous ownership remains unresolved. Source base access gates external member-pointer lookup independently of legacy meta-object access; unavailable access proof cannot authorize an inherited pointer endpoint. (`REQ-QML-016-AC01`) + +2 - Member-pointer, explicit overload-selector/cast, signal-to-signal, functor/lambda and legacy `SIGNAL`/`SLOT` connect fixtures resolve the expected sender/signal/receiver/callable endpoints from typed or signature evidence, including compatible ordinary member targets and supported private-slot meta-object connections; same-name ordinary functions do not satisfy a connection by global label. Lexical type-alias shadowing cannot select a different global class; unsupported alias evidence remains unresolved. (`REQ-QML-016-AC02`) + +3 - Connection records preserve source location, sender/receiver context, literal connection type/flags and conditional registration evidence; ambiguous signatures, dynamic endpoints or unsupported expressions remain unresolved, and declared Auto/Queued/Direct forms do not imply verified runtime thread affinity or delivery order. (`REQ-QML-016-AC03`) + +4 - Direct slot calls, signal emissions, connection declarations and supported disconnect statements remain distinct facts through build/export/query/affected and incremental updates, including supported forward-declaration, header/implementation and inherited-signal ownership changes. Comments/strings do not become signal/slot declarations or connections, and unrelated C++ call regressions still pass. (`REQ-QML-016-AC04`) + + + +### REQ-QML-017 — Bidirectional QML and C++ object integration + +Both integration directions are analyzed: registered or explicitly supplied C++ APIs visible to QML, and C++ loading/accessing QML objects, properties, signals and methods. Supported literal loaders, context/initial-property exposure, object lookup and meta-object access resolve only when source/project evidence establishes the target. + +**Acceptance Criteria** + +1 - Literal `QQmlApplicationEngine::load`/`loadFromModule`, engine URL construction, `QQmlComponent` literal construction/loadUrl followed by create and `QQuickView::setSource` fixtures link C++ loader/access sites to the correct QML component using accepted module/resource metadata; unavailable/dynamic URLs and modules retain unresolved reasons without loading an engine. Supported literal overloads preserve loader, engine and component declaration identity. Source-defined or incomplete loader/engine/URL-wrapper declarations cannot lend SDK authority merely because no complete canonical class was admitted. Source-defined loader/QUrl wrappers, unsupported creation contexts and compilation modes cannot lend SDK semantics; resource QString convenience forms remain distinct from QUrl and absolute fromLocalFile forms. (`REQ-QML-017-AC01`) + +2 - When the QML object provenance is established, `rootObjects`/`rootObject`, literal `objectName`/`findChild` lookup with recursive or direct-only search, supported `property`/`setProperty` and `QMetaObject::invokeMethod` calls resolve to the correct QML object/member and preserve access direction/source evidence, including exact canonical enclosing-method/class ownership for supported header/implementation pairs. Reflective access cannot borrow another object's lexical member; child lookup excludes siblings and honors direct-only depth using accepted parenting evidence. Native child construction requires proven non-widget QObject ancestry; a Q_OBJECT marker or shadowed QObject spelling alone supplies no construction/type-filter authority. Static QMetaObject and QQmlProperty access also requires evidenced SDK API identity; a shadowed class or alias cannot supply that authority. QML `id` alone is not treated as a C++ `objectName` lookup key. (`REQ-QML-017-AC02`) + +3 - Connections from declared QML signals to C++ slots/callables, and from exposed C++ signals to QML handlers, resolve in the appropriate object scope; supported literal `setContextProperty`, `setContextObject` and initial-property exposure preserve provider/provenance facts, while conditional or dynamic exposure remains visibly uncertain. Distinct lexical engine declarations sharing a name cannot share context providers without established declaration identity and provider lifetime. Reassignment, conditional ownership, unknown aliases and expired local providers produce no guessed binding and retain the applicable identity reason. (`REQ-QML-017-AC03`) + +4 - Duplicate object names, computed lookup/method names and unsupported dynamic creation produce no guessed member target; query/affected and cold/full/incremental comparisons retain both integration directions after QML members, loader metadata, C++ exposure or supported native source-ownership changes, with no evaluated QML or executed plugin code. (`REQ-QML-017-AC04`) + + + +### REQ-QML-018 — Installed-project adoption hardening + +Installed Qt/QML analysis supports the newly accepted project/declaration and typed context-provider patterns, reports unsupported or uncertain relationships accurately, and preserves existing graphs on genuine parse, integrity or publication failure. + +**Acceptance Criteria** + +1 - A public qmake fixture with supported unconditional `$$PWD` path prefixes, ordinary unrelated build settings and conditional metadata retains its exact accepted source/module facts and original-byte spans. `$$PWD` uses the current parsed file's directory; paths remain inside accepted corpus boundaries. Qt Creator `QML_IMPORT_PATH` hints, build-time `QMLPATHS` and explicitly configured analysis/runtime roots retain separate provenance. Unevaluated conditions or arbitrary expansion affecting required facts remain uncertain/incomplete, with no invented branch choice or build execution. (`REQ-QML-018-AC01`) + +2 - Public hand-checked valid Qt/C++ declaration and macro fixtures, including empty-brace parameter defaults, `Q_UNUSED` use without a caller semicolon, numeric digit separators and supported reference-return callables, extract their expected canonical declarations and original-byte spans without a false syntax failure. Numeric separators do not obscure later signal/member ownership. Paired malformed/incomplete controls remain rejected; an unsupported semantic relationship stays unresolved rather than being confused with a parser failure. Generic C++ declarations and normalization regressions remain unchanged. (`REQ-QML-018-AC02`) + +3 - Literal context exposure supplied through a supported factory call or member expression resolves the statically declared API type from accepted canonical declarations and preserves provider, return/member type, owner and context evidence. Supported API hops use one object pointer or lvalue reference; source-owned root values/references use `.`, pointers use `->`, and direct address-of objects and implicit `this` retain their declaration identity. Original CV and declarator shape survive transport. Multiple pointers, pointer references, arrays, function pointers, value-return hops, opaque aliases, operator mismatches, unknown, conflicting, conditional or unsupported type/owner evidence produce no definite provider link. No factory, getter or source code executes; declared API evidence does not prove runtime conversion, allocation or const-correct invocation. (`REQ-QML-018-AC03`) + +4 - Supported calls, `Connections` handlers and `signal.connect(handler)` subscriptions through an exposed context provider and a typed child-service property resolve to the correct declared C++ member/signal and QML handler. Scope, lexical shadowing, ambiguity and connection direction survive graph build/reload and query/affected consumers. Source occurrence and endpoint roles authorize the relationship mechanism; changing a read into a call, borrowing another occurrence or substituting an incompatible context grants no bridge. Affected queries use accepted logical source/target direction in directed and undirected graphs, including class-member seeding and actual source-owner propagation. Partial, foreign or contradictory direction metadata grants no dependency. Subscriptions remain distinct from ordinary calls and runtime delivery. (`REQ-QML-018-AC04`) + +5 - A clean installed optional wheel analyzes a public mixed Qt 6/QML project containing the accepted positive forms from AC01–AC04 through the production CLI, both at its project root and at an explicitly configured safe application subroot. Hand-checked facts, root boundaries and consumer results match source evidence without executing the analyzed project. Cold/warm, manual-update and watch results equal clean rebuilds after metadata, context-provider, child-member and signal edits/removal; prior initial-profile and unrelated-language evidence remains applicable or is reverified. (`REQ-QML-018-AC05`) + +6 - Actual parser, resolver and publication failures against that fixture produce bounded stage-specific diagnostics and preserve prior graph, manifest, analysis state and existing successful content-keyed source-cache bytes, including rejected read-only destination replacement on supported Windows hosts. Missing optional parser, rejected root/path expansion and corrupt transport remain explicit failures. JSON serialization rejects partial, foreign, ill-typed or contradictory direction markers before writing, retains previous output and reports QT_EXPORT_DIRECTION; force cannot turn invalid markers into valid-looking endpoints. A corrected retry succeeds and a repeated no-change update is idempotent from the first successful publication, including raw no-cluster output; transient extraction-run lists or absent-versus-empty diagnostic lists alone cannot rewrite accepted graph bytes or modification time. Meaningful diagnostics and source changes remain observable. Diagnostics and public fixtures contain no private source, identifiers, paths or credentials, and analysis runs no Qt application, build hook or plugin. (`REQ-QML-018-AC06`) + +7 - Ambiguous `.h` inputs with supported source-visible C++ declaration markers select the C++ extractor across whitespace, BOM, CRLF and Unicode forms. Comments and string literals cannot supply those markers; plain C and inconclusive headers retain existing C dispatch, and Objective-C dispatch retains its established priority. Header-only edits refresh the selected extractor's source facts through the facade and installed updates without executing the header. (`REQ-QML-018-AC07`) + +Status: **Implemented; all seven criteria locally verified for the bounded +Windows x64/Python 3.12 profile**. Individual success, rejection, update and failure +assignments appear in [traceability](../tests/TRACEABILITY.md#final-adoption-delivery). +The reviewed installed wheel passes 3,024 selected tests; four actual installed +CLI profiles cover CMake/qmake at whole-project and configured safe application +roots. Repetition preserves accepted bytes; ordinary regressions additionally +check modification times, real edits, stale-edge removal and repaired retries. +Valid new content-keyed source-cache entries may be added after successful source +analysis before publication; they do not accept a graph or checkpoint. Existing +successful entries and prior products survive the tested failures. + +The extended provider and metadata contracts supplement the initial REQ-QML-009 +and REQ-QML-017 profiles. Wider hosted-platform verification and baseline full-suite +and type failures remain outstanding delivery evidence; this status does not +claim arbitrary Qt SDK, build or runtime compatibility. + +The added qmake distinctions follow the Qt 6.8 +[variable reference](https://doc.qt.io/qt-6.8/qmake-variable-reference.html#qml-import-path) +and [`PWD` contract](https://doc.qt.io/qt-6.8/qmake-variable-reference.html#pwd). +Context-provider fixtures follow +[Qt context properties](https://doc.qt.io/qt-6.8/qtqml-cppintegration-contextproperties.html); +static type evidence does not establish a factory's runtime result or signal delivery. + +The Qt-specific semantics follow the primary references for +[signals and slots](https://doc.qt.io/qt-6.8/signalsandslots.html), +[QObject connections](https://doc.qt.io/qt-6.8/qobject.html#connect), +[C++ interaction with QML objects](https://doc.qt.io/qt-6.8/qtqml-cppintegration-interactqmlfromcpp.html), +and [meta-object invocation](https://doc.qt.io/qt-6.8/qmetaobject.html#invokeMethod). +These are acceptance contracts for source analysis, not claims that static +relationships prove a particular runtime connection executes. + +The [plan](qt-qml/PLAN.md) defines acceptance examples and exit gates. Update statuses +only when implementation and cited evidence justify them; parser selection or a +passing generic C++ test does not change a Qt/QML requirement to Verified. + +### REQ-QML-019 — Readable large-graph HTML export + +HTML export of an accepted large graph produces a complete, labeled community +representation when its analysis partition or labels are unavailable, with all +exported view data selected initially. View preparation uses local graph evidence +and preserves the full canonical graph. An unavailable or failed HTML view is +reported accurately instead of claiming that a file was written. + +**Acceptance Criteria** + +1 - Above the configured HTML node limit, production export accepts a community partition only when it covers every graph node exactly once without foreign or duplicate members. Missing, empty or invalid partitions are replaced by a deterministic local partition of a view copy through the existing clustering interface, without an API call or rewriting graph/analysis files. The production CLI resolves analysis metadata beside an explicitly selected graph. Public fixtures exercise missing, empty, partial, duplicate and foreign-node partitions through the actual export path. (`REQ-QML-019-AC01`) + +2 - The emitted community view has visible, nonempty labels and a populated legend whose IDs, labels and member counts agree with its plotted groups. Valid supplied labels are preserved; missing labels use the existing local hub-based labeling, and labels for a replaced partition are not reused as if they described the new groups. The inspector distinguishes internal source edges, external source edges and neighboring communities. A closed community with internal links is distinguished from a genuinely unlinked source group; internal links are not fabricated as cross-community lines or self-loops. The artifact identifies its aggregated representation and omitted source details, retains existing escaping and Qt/QML payload protections, and leaves canonical graph nodes, edges and source facts unchanged. Emitted-script/DOM-harness and payload tests establish their exact coverage; an unexecuted browser inspection is not reported as completed browser verification. (`REQ-QML-019-AC02`) + +3 - Unrecoverable grouping reports actionable `HTML_GROUPING_INVALID`; an unusable or skipped aggregate view reports `HTML_VIEW_UNAVAILABLE`; an actual publication failure reports `HTML_VIEW_FAILED`. Each causes the production HTML export command to exit unsuccessfully without a false written-file message, preserving prior valid HTML and canonical graph files. A graph with too many isolated groups to fit the supported aggregate limit reports a bounded failure and focused-graph guidance rather than inventing group membership or bypassing the limit. A corrected retry publishes the expected view, and repeated export of unchanged input retains the same grouping and label behavior under the same clustering backend/configuration. (`REQ-QML-019-AC03`) + +4 - Select All starts checked, and all exported view nodes and edges are active in the visualization datasets before network layout begins. Large source graphs retain the complete, labeled community aggregate and supported display cap; selecting all exported view data does not force raw full-source rendering. The Overview button, top-ten selection mode and reset function are absent. Community filters, search, Select All and Select None retain their selection behavior; the Select All checkbox accurately reflects complete, partial and empty active datasets, including ungrouped nodes. Search restores a filtered result before focusing it. The complete exported metadata remains in the RAW payload, canonical graph data is unchanged, and view choices remain temporary with no added saved camera/filter persistence. Production emitted-script tests exercise checked startup, removal of Overview, filters/search/all/none and payload preservation. (`REQ-QML-019-AC04`) + +Status: **Deferred from the upstream contribution**. AC01–AC04 have historical +fork exporter/emitted-script evidence. The contribution retains baseline generic +HTML grouping and selection behavior. The source-count portion of AC02 is +retained for REQ-QML-020; these broader criteria do not close here. +INC-QML-16 removes the former optional Overview contract and re-verifies the +remaining selection lifecycle, with reviewed installed-artifact proof. Earlier +AC04 startup/Overview results remain +evidence for their own revision and do not verify this removal. +Browser visual inspection, other platform lanes and hosted proof are not claimed +for this revision. Acceptance is limited to +HTML view preparation, selection controls and publication. It does not complete remaining +REQ-QML-018 parser, metadata, provider or whole-project adoption work, and does +not change the canonical graph or require project execution. Assigned work and +exit gates are in [INC-QML-09](qt-qml/PLAN.md#inc-qml-09--readable-large-graph-html-export) +and [INC-QML-16](qt-qml/PLAN.md#inc-qml-16--middle-mouse-navigation-and-overview-removal). + +### REQ-QML-020 — Explicit project membership relationships + +Accepted project-source and resource-alias declarations link to their canonical +source files or QML components in the graph. Metadata lookup and displayed graph +membership describe the same accepted corpus without expanding it or executing +project code. + +**Acceptance Criteria** + +1 - Public CMake, qmake and resource fixtures project each supported literal module/source or resource-alias membership through an independent source-owned `membership_resolution` site to the uniquely accepted canonical file/component endpoint. Accepted C++ file endpoints retain their file role with valid bounded producer provenance; callable, class, foreign, malformed or unrelated semantic metadata cannot borrow that role. The declaration contains its site with context `qt_membership_site`; a resolved site references the target with confidence `EXTRACTED` and context `qt_project_source` or `qt_resource_membership`. Direction, module/alias context and original span survive build, JSON reload and scoped query; raw declarations and existing loader/module lookup results remain unchanged. Canonical spelling and real contained input aliases, including Windows short paths and parent-directory aliases, accept the same source declaration and produce identical membership identity/provenance. (`REQ-QML-020-AC01`) + +2 - Missing, duplicate, conditional, generated or out-of-root targets retain explicit unresolved/unsupported site status, reason and bounded evidence without a target edge. Same-name files in different scopes remain distinct; separately discovered in-corpus symlink sources retain their distinct walked provenance even when their physical target is shared. Alternate spelling of the scan root must not collapse those corpus owners or authorize new target roles. Graph projection neither reads new files nor evaluates expansions, build hooks or QML. Malformed metadata and failed joins retain existing failure diagnostics and prior durable graph/state; force cannot authorize partial membership publication. Canonical input comparison still rejects a foreign input or conflicting declaration identity; an alias cannot authorize out-of-corpus membership or rewrite stored source facts. (`REQ-QML-020-AC02`) + +3 - Source/resource edits, rename, deletion and ambiguity introduction remove stale membership edges; cold, warm and incremental graphs agree and no-change updates are idempotent. Source facts and unrelated-language identities remain stable, and HTML community edges reflect only accepted persisted memberships. Exact automated source, persistence, consumer and failure tests cover each supported form. Canonical and real alias profiles agree through cold/warm extraction, full/manual/watch updates and source removal; repaired retry and no-change repeats preserve accepted output. Earlier native/Qt products refresh under current policy 22 at unchanged package/source versions, including canonical-only followed metadata from policy 21; a failed upgrade retains prior products and repaired retry/repeat completes the same accepted graph. (`REQ-QML-020-AC03`) + +Status: **Implemented; earlier canonical/membership profiles have recorded verification. Followed Qt metadata provenance and current policy-22 integration require new profile evidence**. +The earlier canonical-spelling cases have production, lifecycle/consumer and reviewed installed-artifact +evidence assigned in [traceability](../tests/TRACEABILITY.md#project-membership-projection). +The initial two source/lifecycle suites contribute 37 passing cases to their +recorded reviewed-wheel selection. The INC-QML-39 checkpoint uses Qt policy 18 +and AST schema 12; INC-QML-40's accepted compatibility decision advances policy +19 while retaining schema 12, refresh and producer ownership. Raw facts and existing module/resource +lookups remain unchanged; static membership does not establish runtime component +use. The [verified hosted checkpoint](../tests/TRACEABILITY.md#verified-hosted-runner-checkpoint--10cb15a) +records the applicable Linux and native Windows cases and optional-wheel profiles. +Skips, browser/device procedures and executable Qt application proof remain separate; +passing installed smokes do not establish artifact-origin proof for every source case. +[INC-QML-14](qt-qml/PLAN.md#inc-qml-14--explicit-project-membership-relationships) +owns this additional behavior and its evidence; source containment and viewer +counts do not establish completion. + +The following historical checkpoints preserve the sequence of reproduced failures +and their corrections. Their pending-hosted statements are superseded by the +verified checkpoint above, without converting capability skips into passes. + +INC-QML-39 adds canonical input-alias acceptance after a reproduced installed +smoke failure. The frozen broader Qt/QML source profile passes 2,259 cases with +nine symlink-capability skips. A fresh source-identical artifact passes ordinary +QML/core smoke but initially fails a near-root alias through the shared facade; +INC-QML-40 corrects that distinct defect. INC-QML-41 corrects the reproduced +persistent-path diagnostic escape. Fresh ordinary QML/core and actual near-root +alias smoke now pass with source-identical payloads. Current hosted acceptance remains open; earlier +canonical-spelling evidence does not close these expanded contracts. + +Hosted revision `f95366d` exposes a shared symlink-provenance regression and a +Windows fixture setup gap. INC-QML-42 restores distinct discovered source owners +under policy 20; INC-QML-43 preserves the real nearby-alias fixture when parent +directories also have short names. Their source and fresh installed checkpoint +pass, with actual symlink capability exclusions; corrected hosted proof remains +pending. INC-QML-44/45 correct the adjacent single-alias notification and actual +NTFS short-leaf membership gaps. All 59 new cases pass in source and in the fresh +installed wheel, with real cache/cohort failure retention and repaired parity. +The compatibility profile passes 640 cases with 21 recorded capability/profile +skips; installed contracts pass 102 with eleven file-symlink skips. Corrected +hosted acceptance remains open; overlapping local counts are not a full-suite total. + +Hosted `38bf8cc` exposes a followed-directory setup mismatch and missing manual +update discovery option. INC-QML-46 adds that explicit option and aligns every +entry point and clean comparison, preserving alias and failed-publication assertions. +Production defaults, policy 21 and schema 12 remain unchanged; passing junction +fixtures alone cannot establish the real POSIX profile. +INC-QML-47 aligns the native short-parent parser-spy expectation with accepted +long-spelling admission while retaining exact lexical symlink owner checks. + +### REQ-QML-021 — Middle mouse graph navigation + +Holding the middle mouse button and dragging pans the displayed graph in both +axes. Wheel scrolling retains its existing zoom behavior. Navigation changes +the temporary camera view while preserving source facts, node positions and +community selection. + +**Acceptance Criteria** + +1 - A mouse middle-button press inside the graph starts panning. Subsequent movement, including outside the graph while that button remains held, moves the camera by the corresponding screen displacement at the current zoom scale without moving individual nodes or changing zoom itself. Horizontal, vertical and diagonal drags work in source and aggregated views, including consecutive drags and multiple positive zoom scales. Middle-button press/auxiliary click suppress browser autoscroll and unintended node selection. An on-screen hint explains middle-button drag and wheel zoom. Actual emitted-script tests verify camera calls and active datasets. (`REQ-QML-021-AC01`) + +2 - Button release, a move reporting the middle button no longer held, pointer cancellation, lost capture, window blur or page exit ends panning and restores the previous cursor and selection behavior. A stale or unrelated pointer cannot continue the gesture. Unavailable or failed pointer capture retains a safe cleanup path. Invalid coordinates, non-positive/non-finite camera scale or position, arithmetic overflow and camera API failures cannot produce an invalid move or leave a stuck gesture; a subsequent valid gesture can start normally. Success, interruption and rejection retain source/selection data and add no persistent state or new parser/publication diagnostic. (`REQ-QML-021-AC02`) + +3 - Left-button node selection/drag, right-button behavior, touch gestures, native wheel zoom, search, community filters, Select All/None and the inspector retain their supported behavior. Middle-button movement changes only the camera; no node, edge, source metadata, physics configuration, filter or selection is rewritten. Original graph bytes and exported source payloads remain unchanged. The production exporter, emitted script and reviewed installed artifact exercise the new controls and removal of Overview; unavailable browser appearance or platform checks remain explicit gaps. (`REQ-QML-021-AC03`) + +Status: **Deferred from the upstream contribution**. AC01–AC03 have historical +fork emitted-script and reviewed installed-artifact evidence. Native browser/device appearance and other-platform +interaction remain unverified; the external DOM/network harness does not establish +those system results. Exact evidence and limitations belong to +[INC-QML-16](qt-qml/PLAN.md#inc-qml-16--middle-mouse-navigation-and-overview-removal) +and traceability. +The existing HTML publication and canonical graph contracts remain authoritative. + +## Shared compatibility requirements + +### REQ-CORE-001 — Installed assistant contracts + +Installed assistant hooks invoke the Graphify CLI through the intended executable +identity. Skill installation, refresh and uninstall honor the existing destination +and content contract of the selected operating-system profile. + +**Acceptance Criteria** + +1 - A Codex hook installed from an executable path containing spaces reaches that exact executable and passes `hook-check` as the CLI subcommand. Literal path transport preserves executable identity and argument boundaries through the supported consumer shells, with a nonmatching executable rejected by the regression. (`REQ-CORE-001-AC01`) + +2 - Hermes and Gemini user installations use their platform-owned destinations and include resolvable skill references. POSIX and Windows profiles use their documented home/data and Gemini/shared-agents locations; project installation retains its separate scope. (`REQ-CORE-001-AC02`) + +3 - Refresh writes the correct platform-adapted skill bytes for each uniquely owned stale installed destination and preserves unrelated content. Ambiguous shared Gemini/agents copies retain their bytes and stamp; version checking with automatic refresh disabled reports the actual matching destination/installer. An already current installation is unchanged. Shared-directory ownership isolation remains outside the implemented contract. (`REQ-CORE-001-AC03`) + +4 - Uninstall observes the established global/project scope and explicit user-skill opt-in. Protected shared-agent content is retained according to the current platform contract; missing installations remain a safe no-op. (`REQ-CORE-001-AC04`) + +5 - Windows user-scope hooks preserve the selected Graphify executable through Cmd and PowerShell argument transports when legal path characters include spaces, operators, apostrophes, dollar signs, backticks and paired percent names. A PATH decoy or environment expansion cannot substitute another executable. Missing selected launchers and launcher failures return failure. A missing, inaccessible or unsafe OS-owned PowerShell executable, or an encoded command exceeding the supported Cmd transport limit, rejects hook installation with a bounded actionable reason before changing existing hook JSON or its backup. Repeated installation and uninstall retain unrelated hooks and recognize the owned command through supported status metadata. Project-scope bare invocation and POSIX literal quoting retain their existing contracts. (`REQ-CORE-001-AC05`) + +Status: **AC01/AC05 deferred from the upstream contribution; AC02–AC04 retain +upstream behavior with platform fixture coverage**. All five criteria have +historical fork source and native consumer evidence. The scoped contribution +preserves upstream hook serialization and does not claim AC01/AC05 acceptance. +Other kernels and actual Codex Desktop +event delivery retain separate system boundaries. INC-CORE-01 and INC-CORE-05 +belong to the [compatibility plan](COMPATIBILITY.md#plan-and-acceptance-matrix). + +### REQ-CORE-002 — Safe filesystem admission and recovery + +Source discovery accepts regular files, rejects nonregular or inaccessible inputs +without blocking, and handles an unavailable working directory through the +existing explicit repository-root recovery contract. + +**Acceptance Criteria** + +1 - Regular files and supported links to them are accepted; directories, FIFOs, sockets, devices, broken links and stat failures are rejected without opening or executing the input. Real fixtures run where the platform/account can create them; portable mode/error tests supplement, and do not replace, actual filesystem evidence. (`REQ-CORE-002-AC01`) + +2 - A missing current directory without an explicit repository root returns failure with the existing diagnostic before queue/lock/graph side effects. A valid explicit repository root restores the working directory and publishes the correct source graph. Failed recovery leaves a clear failure result; simulated OS-failure tests and actual POSIX deleted-directory fixtures retain distinct evidence. (`REQ-CORE-002-AC02`) + +3 - Missing-CWD diagnostics distinguish an absent explicit repository root from failure to change to a supplied repository root. A failed supplied root is not described as unset, and diagnostic text does not expose its private absolute path. The failure returns before queue, lock or graph publication and preserves the prior graph bytes. (`REQ-CORE-002-AC03`) + +Status: **Native and hosted POSIX admission/recovery verified for recorded available capabilities**. +The four full Linux lanes retain real FIFO/socket and deleted-current-directory +case outcomes in the [verified hosted checkpoint](../tests/TRACEABILITY.md#verified-hosted-runner-checkpoint--10cb15a). +Privileged device/link capabilities remain explicit exclusions; Windows mode/error +tests do not replace those real-object procedures. Remaining system limits belong +to [compatibility verification](COMPATIBILITY.md#verification-profiles-and-remaining-system-work). + +### REQ-CORE-003 — Portable Terraform scope and provenance + +Terraform analysis preserves distinct directory-scoped declarations and emits +portable source paths and exact source locations in extracted and saved graphs. + +**Acceptance Criteria** + +1 - Same-named variables and outputs in four distinct directories, including punctuation-colliding directory spellings, retain distinct identities and exactly the expected references to their own directory's variable. No reference binds another directory's same-named declaration. (`REQ-CORE-003-AC01`) + +2 - Original UTF-8 LF and CRLF source bytes produce correct portable source paths, source lines, extracted confidence and directed references through the production facade, graph assembly and JSON save/reload. Existing malformed/dynamic input handling is retained. (`REQ-CORE-003-AC02`) + +Status: **Locally Verified for native Windows source/persistence**. +The correction is confined to the test's platform-dependent path expectation. +Exact evidence belongs to [traceability](../tests/TRACEABILITY.md#shared-compatibility-verification). + +### REQ-CORE-004 — Hosted compatibility evidence + +Hosted validation executes the applicable source and consumer tests with their +required tools and disposable services, preserves the tested revision and +dependencies, and reports failures and exclusions explicitly. + +**Acceptance Criteria** + +1 - Ubuntu Python 3.10, 3.12, 3.13 and 3.14 jobs execute the full source suite with all frozen extras, a real C preprocessor, Bash/sh, Node and a reviewed wheel input. A disposable pinned FalkorDB service admits graph commands before its persistence/idempotency tests run. Missing tools or the wrong service fail readiness; they cannot establish passing acceptance through skips. Real POSIX object, permission and deleted-directory fixtures execute where applicable. (`REQ-CORE-004-AC01`) + +2 - A Windows Python 3.12 job executes the assigned Qt/source-identity, installer, filesystem recovery and Terraform compatibility regressions with real Cmd/Windows PowerShell, Git Bash/sh and Node 24.19.0. Independent native hook serialization and general HTML navigation features are excluded from this contribution's assignment. Application resolution selects the first executable in the configured PATH even when multiple applications match; the selected Node version must be 24.19.0. Python test subprocesses launch the admitted absolute Bash/sh executable: native system/CWD search cannot replace it with another shell. Bash forwards to the selected Python interpreter; PATH cannot substitute a global Graphify or invoke an installation fallback. Tool preflight exercises that consumer boundary and fails before collection when a required consumer is unavailable. POSIX-only exclusions retain their separate Linux assignment. (`REQ-CORE-004-AC02`) + +3 - Source jobs retain the workflow/event/ref, PR source head and base, actual checkout SHA, interpreter/tool/dependency versions, pytest exit, JUnit outcomes and skip reasons. The native artifact includes the actual preflight's Bash/sh executable, Python and Node identity report; a local producer file or successful assertion alone cannot establish upload completion. Tests preserve the checkout, tracked source/configuration and installed dependency identities. Failed pytest remains a failed job when evidence/integrity steps run; unavailable evidence is reported explicitly. Disposable test graphs and the job-owned service are removed with confirmed cleanup; failed or unconfirmed cleanup cannot establish acceptance. Fake SDK providers and public fixtures supply test data without credentials or private corpus inputs. (`REQ-CORE-004-AC03`) + +4 - The established push/PR/dispatch triggers and existing required/advisory gate policies remain intact. One PR event owns its validation; no equivalent full feature-push dispatch is added. Obsolete PR revisions can cancel their own workflow runs, while default-branch and explicit recovery runs retain distinct groups and cannot be cancelled by a later PR revision. Jobs use read-only repository permissions. (`REQ-CORE-004-AC04`) + +Status: **Verified for the recorded hosted source/native runner profiles**. INC-CORE-06–09 in +the [compatibility plan](COMPATIBILITY.md#plan-and-acceptance-matrix) owns readiness, +workflow validation, publication and exact-revision evidence. Local passing +tests and configuration checks do not establish hosted acceptance. The +[verified checkpoint](../tests/TRACEABILITY.md#verified-hosted-runner-checkpoint--10cb15a) +binds actual jobs, archives, individual required cases, integrity and cleanup to +their tested source/base/checkout. Baseline typing, advisory security and separate +application/device procedures are outside this runner-profile verification. + +## Current-upstream integration profile + +INC-QML-49 retains existing externally observable requirements while integrating +upstream package 0.9.76. Schema 13 retires incompatible prior AST entries; Qt +policy 22, semantic cache ownership, logical edge mechanisms and corpus bounds +remain authoritative. Exact named Qt metadata and installed-source exclusion +must coexist; original parallel links retain validated Qt direction through +serialization and qualified path queries. These are REQ-QML-002, REQ-QML-003, +REQ-QML-010, REQ-QML-011, REQ-QML-018 and REQ-QML-020 acceptance boundaries, +with REQ-CORE-004 owning hosted evidence. No new numeric requirement is allocated. + +Status: **Implemented; historical integrated source/native/installed hosted +profiles verified at 8b6c9d2; scoped contribution proof pending**. +Earlier Verified labels apply to their recorded bounded source/artifact/runner +revisions. They do not establish current-upstream integration acceptance. Exact +new assignments, executed profiles and remaining gaps belong to traceability. + +INC-QML-50 corrects followed Qt metadata/QML provenance under REQ-QML-020-AC02: +physical containment remains required, and each separately admitted lexical source +retains its own facts, IDs, nested references and original-byte locations. Qt +policy 22 refreshes prior canonical-only products at unchanged input; combined +AST schema 13 and semantic-cache ownership remain unchanged. Default exclusion, +foreign/ignored/nonregular rejection and transactional output retention still +apply. The correction's recorded native and POSIX hosted profiles pass; the reproduced +older omission invalidates alias-provenance acceptance for that earlier state. + +INC-CORE-10 corrects native admission of the upstream external-call subprocess +fixture under REQ-CORE-004-AC02. The isolated child retains required Windows home +and system fields so the extraction assertions execute; source/provider scope +and production behaviour remain unchanged. The exact native/Linux case and +unchanged inputs require current integration-run evidence. + +INC-QML-51 corrects the platform runner guard under REQ-QML-001-AC01/AC03, +REQ-QML-014-AC01/AC03/AC04 and REQ-CORE-004-AC03. Native source lanes admit QML, +Qt and joint upstream/Qt tests while the Linux wheel lane selects only its +artifact contracts. The guard executes the actual trusted selection expression +against a temporary corpus, checking admitted and excluded files in both modes; +optional/core installation and isolated offline assertions remain required. +The original guard fails at integration source dbc4859 because it expects the +former two-prefix spelling. Local correction and all three reviewed-artifact +contracts pass; the corrected recorded hosted source profiles also pass. + +INC-QML-52 corrects the metadata-growth fixture under REQ-QML-002-AC03 and +REQ-QML-012-AC01/AC04. Native file identity remains authoritative before bounded +metadata reads; a simulated understated size must retain the real stat identity +so oversized bytes reach and fail the intended metadata-size boundary. The +scope-limit and size-limit rejection assertions remain unchanged. All 35 metadata +boundary cases pass on native Python 3.10/3.12/3.13/3.14 after the correction; +exact corrected hosted evidence is recorded in the integrated checkpoint above. diff --git a/docs/qt-qml/ARCHITECTURE.md b/docs/qt-qml/ARCHITECTURE.md new file mode 100644 index 0000000000..eb0a74d958 --- /dev/null +++ b/docs/qt-qml/ARCHITECTURE.md @@ -0,0 +1,866 @@ +# Qt and QML analysis architecture + +The contribution retains truthful aggregate source-edge counts as a +REQ-QML-020 membership-projection prerequisite. General partition recovery, +selection redesign and navigation remain deferred. The counts supplement +upstream degree display without changing source graph ownership. + +Current contribution scope: [UPSTREAM_CONTRIBUTION.md](UPSTREAM_CONTRIBUTION.md) +retains REQ-QML-001–018 and REQ-QML-020, supporting shared-source contracts and +applicable compatibility infrastructure. General HTML features under +REQ-QML-019/021 and D12/D16, the generic viewer portions of INC-QML-09/13/16, +and native Codex transport under DEC-CORE-01 are deferred for this submission. +Qt HTML metadata, tooltips and graph-preservation contracts remain in scope. + +Historical adoption status: INC-QML-00 through INC-QML-07 are implemented and +verified for their recorded bounded static source profiles. The complete fork is +frozen at [`82a4f29`](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4), +with [integrated hosted evidence](../../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2) +bound to its source. Those passes do not verify the narrowed contribution head. +The historical audit describes Graphify 0.9.74 at upstream commit +`0b60d47e6cd9338c51143f39f35b6c45c8453385` on 3 October 2026; that imported +revision lacked QML extraction. Retained implementation includes QML/JavaScript +scopes, native Qt events and bidirectional bridges, literal +project/resource/type-description metadata, and conservative configuration-aware +refresh. +[IMPLEMENTATION.md](IMPLEMENTATION.md), [VALIDATION.md](VALIDATION.md) and +[traceability](../../tests/TRACEABILITY.md) own revision-specific acceptance +evidence; [AUDIT.md](AUDIT.md) remains the baseline record. + +The baseline/reuse review includes [feature request #1716](https://github.com/Graphify-Labs/graphify/issues/1716) and [implementation proposal #1748](https://github.com/Graphify-Labs/graphify/pull/1748), covering QML and Qt/C++ bridging. The recorded proposal review and parser decision informed the local implementation. Refresh the proposal's head before upstream delivery; its historical installation instructions and an open PR do not establish shipped support. This design retains the wider metadata, bidirectional bridge, incremental and consumer contracts needed for complete support. + +## Objective and boundaries + +The [follow-up audit](FOLLOWUP_AUDIT.md) invalidates broader receiver, engine and +type-alias scope claims for the reproduced cases at `95adbdc`. Corrective +INC-QML-17–20 preserve this architecture while establishing actual object, +declaration, type and literal-loader authority. They have bounded local evidence; +historical and correction passes do not imply +those semantic joins are correct. + +Extend Graphify's existing deterministic extraction pipeline so an agent can trace a QML component, its imports, property dependencies, handlers, JavaScript helpers, and explicitly exposed C++ API. Make both integration directions first-class: C++ APIs supplied to QML (REQ-QML-008), native Qt C++ signal/slot connections and emissions (REQ-QML-016), and C++ loading and accessing QML object APIs (REQ-QML-017). Keep the implementation suitable for small upstream contributions. Preserve existing public APIs, graph formats, installation behavior, and analysis of other languages. + +The default analysis reads source files and declared metadata. It does not instantiate QML, load plugins, run CMake or qmake, execute JavaScript, or require Qt, PySide, PyQt, libclang, a compiler, or Node.js. A locally installed Qt tool can be an optional validation oracle in development or an explicitly selected enrichment adapter later. It is never necessary for ordinary extraction. + +Static analysis must report its limits. A literal declaration can be certain while its runtime availability remains unknown. Missing import paths, dynamic creation, context properties, conditional compilation, overloads, and runtime-selected resources must remain visible as unresolved or conditional facts. + +Non-goals for the first release: + +- Full QML engine equivalence, executable evaluation, runtime signal ordering, lifecycle or thread analysis. +- Replacement of Graphify's generic JavaScript/C++ extractors, resolver registry, graph storage, CLI, or MCP API. +- A mandatory graph-wide migration to a multigraph or a new required node/edge schema. +- Full CMake/qmake interpretation, macro expansion, automatic SDK discovery, fetching remote imports, or loading binary plugins. +- Qt Widgets Designer `.ui` XML, binary `.rcc`, compiled QML caches, Qt for Python registration, and QML written inside arbitrary strings. + +## Declared compatibility targets + +The agreed first-release target is ordinary Qt 6 QML and Qt Quick projects, with both CMake and qmake metadata support. Current source fixtures distinguish Qt 6.5 and Qt 6.8 syntax profiles; CMake modules using `qt_add_qml_module` and literal qmake declarations remain INC-QML-05 work. These are fixture targets, not a claim of complete support for either SDK release. Qt 6.8 module-layout policy differences need their own fixtures. Additional Qt 6 versions earn a support claim only when relevant constructs pass the corpus; unknown syntax produces coverage diagnostics. + +Qt 5.15 semantic compatibility forms a separate legacy profile. Versioned imports, manual `qmldir`, qmake, and literal procedural registration are also supported forms to test in Qt 6; they are not deferred solely because older Qt projects use them. Common syntax can parse earlier, but passing Qt 6 fixtures does not establish Qt 5 semantic support. Qt module import versions are not Qt library release numbers and must be stored separately. + +The declared hosted matrix is Ubuntu, Windows and macOS runners with Python 3.10/3.12/3.13/3.14, as recorded in [PLATFORM_MATRIX.md](PLATFORM_MATRIX.md). Graphify's package baseline is Python 3.10+; published grammar wheels alone do not extend that tested matrix to other architectures, Python 3.11, musl or PyPy. Source analysis is independent of the application's deployment platform. + +Local development validation is Windows. INC-QML-03 through INC-QML-06 have revision-specific hosted source/artifact evidence in the platform matrix; INC-QML-07 proof applies to the reviewed source head recorded in VALIDATION.md. Qt 6.5/6.8 fixture profiles do not assert QML-engine equivalence, runtime dispatch or an installed Qt SDK. + +## Existing integration contracts + +The baseline has the extension points needed for an additive implementation: + +| Contract | Existing source | Consequence for Qt/QML | +| --- | --- | --- | +| Filename classification and collection | `graphify/detect.py`, `graphify/extract.py:collect_files` | Add extensions and explicit filename predicates; `qmldir` and `CMakeLists.txt` cannot be covered by suffixes alone. | +| Per-file extraction and facade | `graphify/extractors/`, `graphify/extract.py` | Add separate extractor modules; keep facade re-exports and package registry consistent. | +| Root and ID normalization | `graphify/extract.py:extract`, `graphify/ids.py` | Pass the explicit scan root. QML scope identities must survive root remapping and normalization. | +| Cross-file resolver registry | `graphify/resolver_registry.py` | Reuse the post-extraction seam, while accounting for activation on metadata-only changes. | +| Incremental context | `extract(..., resolution_context_nodes=..., resolution_context_edges=...)` | Unchanged declarations can extend resolution, but changed metadata may require recomputation of edges sourced by unchanged QML. | +| Schema | `graphify/validate.py` | Nodes retain `id`, `label`, `file_type`, `source_file`; edges retain `source`, `target`, `relation`, `confidence`, `source_file`. | +| Confidence | `EXTRACTED`, `INFERRED`, `AMBIGUOUS` | Reuse these three labels, with structured evidence explaining what was resolved. | +| Cache | `graphify/cache.py` | Current AST cache is namespaced by package version and cache schema. Cache source facts, not project-context decisions. | +| Graph construction | `graphify/build.py:build_from_json` | Graph/DiGraph keeps one edge per node pair. Distinct Qt facts cannot depend on parallel edges surviving. | +| Queries and exports | `affected.py`, `cli.py`, `serve.py`, `callflow_html.py`, exporters/wiki/report | Relation filters and field serialization must be checked explicitly; schema acceptance alone does not establish usability. | + +The migration rules in `graphify/extractors/MIGRATION.md` concern moving existing extractors verbatim. Add QML functionality as feature contributions without combining it with unrelated extraction refactors. Modules under `extractors/` must not import `graphify.extract`. + +## Pipeline and module seams + +```mermaid +flowchart TD + A[Discover source and declared metadata] --> B[Per-file parsers] + B --> C[Canonical declarations and source facts] + C --> D[Project module and resource index] + D --> E[QML lexical and type resolution] + E --> F[Qt events and bidirectional C++ integration] + F --> G[Compatible graph projection and diagnostics] + G --> H[Existing validation and graph build] + H --> I[Queries, MCP, reports, wiki and exports] + C --> J[Generic per-file AST cache] + J --> D + K[Unchanged incremental context] --> D +``` + +The pipeline shows current source/index/bridge/consumer ownership. Generic AST +caching remains available, while Qt source/metadata and native syntax in a Qt +context bypass syntax cache under the conservative refresh policy. + +Implemented seams and compatibility boundaries: + +| Module | Responsibility | +| --- | --- | +| `extractors/qml.py`, `qml_ast.py`, `qml_declarations.py`, `qml_facts.py` | Implemented: lazy parser, bounded source input, portable declaration/scope IDs and lossless literal metadata. `.qml`/`.ui.qml` use the same parser. | +| `extractors/qml_metadata.py` | Literal `qmldir` records, versions, singleton/internal/script exports and import/dependency facts; `.qmltypes` has a separate bounded reader. | +| `qml_module_index.py`, `qml_scope.py`, `qml_resolution.py` | Implemented: per-run read-only lookup contract over accepted facts; document-local aliases, observed module versions, component/member scopes and source-owned resolution sites. | +| `extractors/qml_expressions.py`, `qml_js_scopes.py`, `qml_scripts.py`, `qml_script_syntax.py` | Implemented: static bindings/read/call/alias/handler facts and bounded QML-owned overlays of admitted JS resources. | +| `qml_relationship_lookup.py`, `qml_relationships.py`, `qml_projection.py`, `qml_safety.py` | Implemented: alias/script lookup, relationship projection, narrow script cross-language proof and publication guards. | +| `extractors/qt_cpp_syntax.py`, `qt_cpp_exposure.py`, `qt_cpp_events.py`, `qt_cpp_access.py` and focused helpers | Source-local normalization, meta-object/registration, emission/connect/disconnect and loader/provider/access facts referring to accepted canonical C++ IDs. | +| `extractors/qml_cmake.py`, `qml_qmake.py`, `qml_resources.py`, `qml_types.py`, `qml_project_read.py` | Bounded literal build/resource/tooling facts and original-byte evidence; unsupported expressions never execute. | +| `qt_project_index.py`, `qt_resource_index.py`, `qt_qml_bridge.py`, `qt_event_resolution.py`, `qt_qml_access_resolution.py` | Accepted project/resource membership, native event endpoints and both QML/C++ directions; preserve canonical declarations and isolate per-run lookup. | +| `qt_incremental.py`, `qt_analysis_state.py` | Conservative accepted-corpus refresh and parser/root/admission compatibility checkpoints; publication owners commit state. | +| `qt_qml_search.py`, `qt_affected.py`, `qt_html.py`, `qt_relationship_views.py`, `qt_export.py`, `qt_coverage.py` | Local INC-QML-07 consumer views, typed direction, export transport and unresolved-site coverage. | +| Existing facade and registry | Dispatch/re-export extractors, forward the scan root, and invoke the resolver; retain current callers. | + +Per-file extraction is independent of filesystem-wide lookup and runs in worker processes. The facade and workers forward the explicit root and immutable native-cache policy. No new ambient Qt root/state is introduced. Indexes are scoped to one extraction run; MCP loads persisted graph facts rather than retaining a project resolver cache. + +The resolver rebuilds bounded project indexes each run. It owns lookup tables and borrows accepted declarations read-only; fresh expression sites receive resolution status, while borrowed incremental context is never mutated. Deterministic tie handling is required. Hosted parity evidence is revision-specific; final INC-QML-07 changes require their own recorded head. + +## Parser decision and packaging gate + +D2 is accepted for the optional language-pack adapter. The +[INC-QML-00 decision](PARSER_DECISION.md) records installed ABI, license, footprint, +offline invocation, executed platform lanes and remaining evidence gaps. + +QML is a dedicated grammar with embedded JavaScript. Parsing all QML as JavaScript or recovering types with regular expressions cannot support reliable scope and bindings. + +Verified candidate evidence as of 3 October 2026: + +| Candidate | Verified facts | Decision | +| --- | --- | --- | +| Standalone `tree-sitter-qmljs==0.3.1` | [PyPI release](https://pypi.org/project/tree-sitter-qmljs/0.3.1/) lists one source distribution and no wheels. [Grammar license](https://github.com/yuja/tree-sitter-qmljs/blob/master/LICENSE) is MIT. [Python metadata](https://github.com/yuja/tree-sitter-qmljs/blob/master/pyproject.toml) declares optional `core` dependency `tree-sitter~=0.21`; [binding source](https://github.com/yuja/tree-sitter-qmljs/blob/master/bindings/python/tree_sitter_qmljs/binding.c) returns an integer language pointer. | Do not add as a mandatory dependency or assume compatibility with Graphify's `tree-sitter>=0.23,<0.26`. Test a maintained binding/package path before selection. | +| `tree-sitter-language-pack==0.11.0` | Already used by Graphify's optional R/Erlang extras. [Release page](https://pypi.org/project/tree-sitter-language-pack/0.11.0/) lists `qmljs`/`qmldir`, MIT/Apache licensing, and wheels for Windows x64, Linux x64/ARM64, macOS x64/ARM64. [Versioned metadata](https://github.com/Goldziher/tree-sitter-language-pack/blob/v0.11.0/pyproject.toml) requires `tree-sitter>=0.25.2`. [Grammar manifest](https://github.com/Goldziher/tree-sitter-language-pack/blob/v0.11.0/sources/language_definitions.json) pins QML grammar revision `0bec4359a7eb2f6c9220cd57372d87d236f66d59`. | Selected optional adapter, with installed Windows API/parser evidence in the decision record. Dependency intersection is `>=0.25.2,<0.26`; published wheels do not establish unexecuted platform lanes. | +| Dedicated maintained QML Python wheels | A smaller optional dependency could avoid a many-language pack. It would require compatible bindings, generated grammar, platform build/release maintenance, and preserved notices. | Alternative if the pack fails coverage/size criteria; do not silently create a permanent vendored parser. | +| Qt tooling | [qmllint](https://doc.qt.io/qt-6.8/qtqml-tooling-qmllint.html) and [qmltyperegistrar](https://doc.qt.io/qt-6.8/qtqml-tooling-qmltyperegistrar.html) provide Qt-aware tooling. | Optional test oracle or future explicit metadata adapter. Avoid embedding Qt private parser APIs or requiring a Qt installation. | + +The [QML grammar](https://github.com/yuja/tree-sitter-qmljs/blob/master/grammar.js) defines import/version/alias, property, signal, inline component, and enum constructs using a TypeScript-derived grammar. Its [README](https://github.com/yuja/tree-sitter-qmljs#pitfalls) explicitly documents that grouped property notation parses as an object definition. These facts identify spike cases; they do not prove semantic correctness or coverage of modern Qt. + +INC-QML-00 recorded proposal #1748 reuse findings, installed API/ABI, grammar/licensing and Windows parser evidence in [PARSER_DECISION.md](PARSER_DECISION.md). Hosted proof now extends through INC-QML-06 at recorded heads. The optional adapter is selected; final INC-QML-07 release proof and the bounded support gates below remain applicable. + +The optional `qml` extra uses the pinned language-pack adapter; the standard-library `qmldir` reader does not require it. Parser absence/load/syntax failure emits no authoritative QML nodes or edges and a diagnostic; CLI/watch reject incomplete publication and retain the prior graph. No lexical fallback is implemented. Any future fallback needs a separate reduced-coverage contract and must not masquerade as a complete AST result. + +## Source facts, identity, and graph projection + +### Identity and scope + +IDs are derived from the explicit repository root and normalized with Graphify's existing ID utility. Use the complete repository-relative filename, including suffix, so `Panel.qml`, `Panel.ui.qml`, `Panel.qmltypes`, and `Panel.cpp` remain distinct. Keep source spelling case-sensitive in resolution even though Graphify normalizes persisted IDs using casefold/NFKC. + +A QML declaration identity consists of `(relative file, component scope, object scope, declaration kind, exact name/signature)`. Append a deterministic digest of the exact identity tuple before normalization to prevent different case or punctuation spelling from collapsing. Named properties/functions/signals should survive line insertions. Inline components get their own named component scope. An object with an `id` uses that id within its component scope; an anonymous object uses a deterministic structural anchor and sibling ordinal. Anonymous IDs may change when siblings are reordered; document that limitation instead of claiming full edit stability. Line/byte ranges describe evidence and are not the sole identity for named declarations. + +Module/type lookup keys are separate from display IDs: `(project/target, module URI, export name, major/minor availability, import root/provider)`. An alias is document-local lookup context, not a new global module name. An `id` in one component does not match an equal `id` in another file or component. Test relocation, duplicate filenames, `foo` versus `Foo`, Unicode normalization, inline-component shadowing, and Windows separator handling through the complete extract/build/export path. + +### Additive contract + +Retain `file_type="code"` for source/metadata nodes. Use existing optional `type` values where appropriate (`file`, `class`, `function`, `property`) and a namespaced `metadata.qml` or `metadata.qt` object for precise kinds. Do not add new required top-level fields or confidence labels. The namespace has its own `contract_version` so future extractors can reject stale fact shapes while old consumers ignore optional metadata. + +Implemented QML metadata has `contract_version=1`, `kind`, exact literal names/types, `component_key`, `object_scope_key`, parent/enclosing scope keys, imports/versions, modifiers and UTF-8 spans as appropriate to each fact. Fixed-width scope hashes avoid multiplying parent-ID length. Occurrence sites carry their owner and exact expression span. `raw_values` holds bounded base64 companions for literal strings so HTML sanitation does not alter semantic lookup; the getter validates map/field types, base64/UTF-8 and the 512-encoded-byte bound. Consumers use `qml_facts.qml_metadata` rather than interpreting escaped display strings. Corrupt transport rejects analysis rather than changing a target silently. + +Keep facts necessary for future resolution on their owning nodes in bounded metadata: imports, type exports, member signatures, raw reference paths, and resolution status. Per-file transient `qml_facts` may aid coordination, but unchanged-file resolution cannot depend on transient data discarded by graph serialization. Keep fact ownership explicit so changing/deleting a source removes its facts and derived edges. Avoid storing whole files, credentials, SDK paths, or machine-specific configuration in graph output. + +The graph is a conservative projection of these facts: + +| Fact | Compatible projection | Context/evidence | +| --- | --- | --- | +| Component/object/member declaration | Owner `contains` declaration | `metadata.qml.kind=declaration`, exact source span | +| Module/directory/JS import | Import declaration `contains` resolution site; site `imports` provider/namespace/file | `qml_import_resolution`; URI/path, version, alias, import kind | +| QML component root/base | Type/member lookup follows a known source component | Explicit `inherits` projection remains planned | +| Object creation/type use | Object `contains` type-use site; site `uses` resolved component | `qml_type_resolution`, provider evidence | +| Property alias | Alias member `contains` alias site; site `references` resolved target | `qml_alias_target`, bounded alias-chain evidence | +| Property binding | Owner `contains` binding; binding `contains` per-occurrence read; read `uses` target | `qml_binding_read`; possible reads, no evaluation order | +| Handler | Object `contains` handler; handler `references` signal | `qml_signal_subscription`; declaration and subscription remain separate | +| Direct function call | Owner `contains` call site; site `calls` resolved function | `qml_js_call` or `qml_script_call`, lexical/type/import evidence | +| Signal emission | Caller `contains` emission site; site `uses` declared signal | `qml_signal_emit` or `qt_signal_emit`; do not label event propagation as an ordinary direct call | +| Qt connection | Caller `contains` connection site; site `references` signal and `uses` receiver callable/signal | `qt_connect_signal`, `qt_connect_receiver`; sender/receiver/context, signature, declared connection flags and condition evidence | +| Qt disconnect | Caller `contains` disconnect site; site `references` resolved connection or explicit endpoints | `qt_disconnect`; no assumed removal of every possible runtime connection | +| Qt registration | Export/provider node `uses` existing C++ class | `qt_type_registration`, macro or literal registration evidence | +| C++ property access | QML binding/handler `uses` C++ property node | `qml_cpp_member`, registration + receiver + member evidence | +| C++ loads QML | Caller `contains` loader site; site `uses` QML component | `qt_cpp_qml_load`, literal URL/module and engine/component provenance | +| C++ finds QML object | Caller `contains` lookup site; site `references` scoped QML object | `qt_cpp_qml_find_child`, literal `objectName`, type/options and root evidence | +| C++ accesses QML API | Caller `contains` access site; site `uses` QML member | `qt_cpp_qml_property_read`, `qt_cpp_qml_property_write`, `qt_cpp_qml_invoke`; API variant, member signature and object provenance | +| Context/initial properties | Source owner `contains` exposure site; site `uses` provider/member | `qt_context_exposure`, `qt_initial_property`; named mapping scoped to the supplied context/component | +| Resource reference | Site `references` resource mapping/file | `qml_resource`, URL + prefix/alias evidence | + +Binding/handler/registration/resource and Qt event/access site nodes distinguish relations that would otherwise compete for one edge between the same pair. Keep sender, receiver, context and bridge direction in `metadata.qt`, including distinct endpoint roles when a signal connects to itself. They do not eliminate all duplicate edges: preserve the bounded underlying facts and adopt a deterministic projection rule. Round-trip tests must show which facts survive the simple graph. A multigraph change is a separate upstream design proposal if precision later demands it. + +Do not globally put QML, JavaScript, and C++ into one language family. The baseline deliberately prevents inferred calls crossing unrelated language families. A direct QML-to-C++ `calls` edge requires a narrow, validated Qt bridge marker checked by the builder and tests; until that contract exists, expose member dependencies through `uses`. Changing an edge to `EXTRACTED` merely to bypass the cross-language guard is invalid. + +The implemented builder exception covers only dedicated QML script import/call sites. Import edges prove the accepted script-file ID/path/role; call targets are dedicated QML script-function overlays. A context string or same label alone cannot enable a bridge. On undirected JSON reload, `paths.load_node_link_graph` restores `_src`/`_tgt` for contract-versioned QML edges from serialized endpoints. Consumers must use that retained orientation rather than NetworkX iteration order. This restores producer direction without converting the graph or promising that every consumer already exposes every mechanism. + +### Evidence and confidence + +Every edge has a source file and relation-site location. Resolved edges also carry bounded namespaced evidence: method, source span, relevant import/export/registration IDs, candidate count, selected provider/target, and assumptions. Store repository-relative evidence paths and preserve `source_location="L"` for current consumers; optional end/column/byte fields can provide precise spans. + +- `EXTRACTED`: an explicit source relationship with uniquely established endpoints, such as containment or a local lexical reference. A literal import declaration can be extracted even when the module's runtime implementation is unavailable. +- `INFERRED`: a unique target derived through declared imports, type/member data, or a narrowly proven C++ bridge. Explain that chain. Static exposure does not prove the code executes. +- `AMBIGUOUS`: competing providers or a dependency requiring unresolved context. Prefer a diagnostic or scoped unresolved reference node over edges claiming that every candidate is used. + +Unknown is distinct from external. Current missing providers/members remain source-owned unresolved sites; external SDK leaf stubs are not emitted. A future typed external leaf would need URI/type/version identity and could not pretend SDK source is present. Do not create global nodes for every unresolved `width`, `model`, or `submit`. Confidence scores, if emitted, express policy rather than a calibrated probability and do not override the categorical/evidence contract. + +## Resolution semantics + +### Imports, exports, and versions + +Collect module imports (`import Example.Controls 1.2 as Controls`), directory imports, JavaScript aliases, and implicit local-directory visibility. Qt supports qualified namespaces and directory/JavaScript import forms; these need distinct lookup behavior. Resolve only against the scanned repository and explicit, declared import roots. Do not read ambient `QML_IMPORT_PATH` or search a developer's SDK silently. See [QML import syntax](https://doc.qt.io/qt-6/qtqml-syntax-imports.html). + +`qmldir` supplies module URI, versioned exports, singleton/internal flags, JS exports, plugin/typeinfo declarations, dependencies/imports, and preferred paths. Preserve the difference between a packaging dependency (`depends`) and an imported visible namespace. Module imports may share a qualifier, while a JS resource has its own alias. A module URI is not sufficient to pick between competing project targets. Include declared import-root priority only when configured; otherwise record duplicate providers. + +Keep requested versions, exported versions, and C++ member revisions independently. Select a provider only when the supported Qt profile and available metadata establish visibility. Test versionless, major-only, exact major/minor, missing version, incompatible major, singleton, internal, and multiple-export-version cases. Unknown revisions remain unresolved rather than choosing a filename or the highest version globally. [Qt's module definition documentation](https://doc.qt.io/qt-6.8/qtqml-modules-qmldir.html) distinguishes exports, imports, dependencies, and tooling-only type information. + +`.qmltypes` is declarative tooling metadata, not executable QML or a replacement for runtime registration. Parse its module/components, prototypes, exports/revisions, properties, methods, signals, enums, singleton/creatability and known flags with a supported format profile. Unknown fields are retained as coverage diagnostics. Imported metadata is evidence for an API surface; it is not proof that a matching plugin is built or loaded. Source/generated metadata disagreements are reported, with both origins preserved. + +Current module lookup defaults to the explicit scan root. CLI/watch and generated assistant AST guidance inspect ordered project-relative `GRAPHIFY_QML_IMPORT_ROOTS` and pass the roots explicitly to extraction/indexes; direct API callers provide `qml_import_roots`. It accepts unversioned provider directories and explicitly requested `.major`/`.major.minor` layouts. Requested versions need observed export/module availability; type introduction selects the latest compatible export only after module availability is established. Script exports have a separate lookup role from object types. Missing roots/versions, internal exports, conflicting eligible targets and unsupported `qmldir prefer` paths retain reasons. Separate INC-QML-05 indexes supply accepted build/native/resource metadata. Generated `.qmltypes` keeps tooling provenance and conflict evidence, without proving runtime plugin availability. + +Bounds are 1 MiB/10,000 meaningful `qmldir` records, 50 diagnostics/evidence/candidate entries, 32 module/inheritance/alias depth and 1,024 module-query steps. The member-continuation API accepts at most 32 parts; collected qualified source paths are capped at 256 characters. Limits produce explicit rejection/coverage reasons; truncating lookup identity is prohibited. + +### Components, properties, and JavaScript + +Resolution follows a supported subset of QML's actual scopes: JavaScript lexical locals/parameters first, component IDs, the binding's scope-object members, component root members, and imported types/JS. Inheritance lookup requires a known type. Keep named inline components, `Component` bodies, delegates, and outer instance-context lookups explicit; do not treat all descendants as sharing one unrestricted property namespace. [Qt scope documentation](https://doc.qt.io/qt-6.8/qtqml-documents-scope.html) explains component scope and runtime instance hierarchy; context-dependent outer lookups stay conditional. + +Resolve grouped properties against the known property's declared type. Resolve attached properties/handlers through an attaching type. Grammar shape or initial capitalization alone cannot decide whether `font { ... }` is a property group or an object instance. If type information is missing, retain the ambiguous syntax fact and suppress a confident instantiation edge. Aliases refer to scoped objects/properties; they are not arbitrary JavaScript expressions. Required/default/readonly properties and list/object values are declaration facts. [Qt object attributes](https://doc.qt.io/qt-6.8/qtqml-syntax-objectattributes.html) provides the semantic distinctions. + +Embedded JavaScript uses the QML parser's subtrees or a byte-preserving JS adapter. Share existing JS traversal utilities where safe, but do not apply Node/npm resolution to QML script imports. Ordinary QML `.js` supports `.pragma library` and `.import` directives, while `.mjs` uses ECMAScript module semantics. Detect that context and preserve original line/column offsets if directive masking is needed. JS files shared with an ordinary web project must retain normal Graphify JS behavior. See [JavaScript resource imports](https://doc.qt.io/qt-6.8/qtqml-javascript-imports.html). + +Recognize local `signal` declarations, `onSignal` handlers, property change handlers, attached handlers, and `Connections` with a statically resolved `target`. A handler-to-signal dependency and calls in its body are separate facts. Derive `onChanged` from the exposed property, including C++ NOTIFY information; do not derive it solely from the C++ notifier's spelling. Dynamic `Connections.target`, `signal.connect()` callback replacement, disconnection, and lifetime behavior are recorded without simulating execution. See [Qt signals and handlers](https://doc.qt.io/qt-6.8/qtqml-syntax-signals.html). + +Current expressions resolve component-local reads, typed-property continuations, bounded alias chains, local functions and source-visible signals. JS parameters, block/loop/catch bindings and function-scoped `var` shadow QML names; reassigned local callables stay dynamic. A per-use shadow flag survives bounded lexical-name display lists. Pure assignment targets are writes; receiver/index evaluation and augmented assignments retain their reads. No getter/expression is invoked. `Connections` needs a static supported target; mixed legacy/function handler styles retain the ignored-function reason. Property change handlers use a separate inferred notify-signal node. Attached providers, dynamic targets and full signal lifetime behavior remain unresolved. + +`Component`, `delegate` and `sourceComponent` name-based barriers conservatively separate template-body IDs from their enclosing document. These are scope safeguards, not type-proven Qt runtime context: a custom type/property using those spellings can be conservatively unresolved. Creation/outer context, arbitrary model roles and implicit runtime ancestry remain unproven. `this` uses the source owner; `parent` needs an explicit `static_parent_scope_key`, not lexical containment alone. + +Accepted `.js`/`.mjs` imports get distinct QML-owned file/function/use overlays; generic JS declarations and callers keep their original graph. Classic `.pragma library`/literal `.import` directives are masked without changing byte offsets, while supported ESM exports control `.mjs` visibility. Script bodies do not inherit an arbitrary importing QML document's IDs or context. Computed dispatch, unsupported re-exports and full dynamic JS dataflow are coverage gaps. Overlays read only admitted script paths, with a 5 MB/100,000-node/depth-256 input limit and at most 256 enriched script files. + +`.ui.qml` uses the same QML extraction pipeline and preserves its compound suffix identity. Its companion `.qml` implementation can reference exported aliases using normal module/type rules. Qt Creator [documents a restricted UI-file subset](https://doc.qt.io/qtcreator/creator-quick-ui-forms.html); Graphify analyzes files as written and may report restrictions, but does not rewrite them or claim to enforce every Designer rule. + +### C++ and Qt meta-object exposure + +This subsection describes the bounded INC-QML-04/INC-QML-05 source contracts. Larger +compiler/runtime behavior remains outside the implemented profile. + +The source-local Qt overlay borrows canonical class/member IDs from the existing C++ extractor. Supported `Q_PROPERTY`, `Q_INVOKABLE`, signal/slot sections, registration macros and their flags retain original source evidence without running `moc`. A public C++ method is not automatically callable from QML. Member exposure requires the accepted registration/provider and declared meta-object surface. Supplied `.qmltypes` contributes generated tooling metadata and conflict evidence, without inventing canonical runtime providers. See [Qt C++ attributes exposed to QML](https://doc.qt.io/qt-6.8/qtqml-cppintegration-exposecppattributes.html). + +Combine declarative registration (`QML_ELEMENT`, `QML_NAMED_ELEMENT`, singleton/uncreatable and version macros) with the enclosing CMake/qmake module declaration. A `QML_ELEMENT` alone does not identify a module URI. Literal `qmlRegisterType`, uncreatable and singleton registrations can supply URI/version/export name when template/type and argument values are established. Dynamic registrations, macro wrappers, factories and complex expressions remain unresolved. [Integration macros](https://doc.qt.io/qt-6.8/qqmlintegration-h.html), [registration functions](https://doc.qt.io/qt-6.8/qqml-h.html), and [defining C++ QML types](https://doc.qt.io/qt-6.8/qtqml-cppintegration-definetypes.html) describe these different paths. + +Supported macro arguments use balanced syntax and original source offsets. `QML_NAMED_ELEMENT(EventDatabase)` takes an identifier; a quoted argument is not the documented syntax. The reuse review of #1748 identified its quoted-name regex/test error; the implemented scanner accepts the documented form and preserves upstream C++ normalization. + +Join QML member usage to a C++ member only with a resolved module export, receiver type, exposed member name, visibility/revision, and unique declaration. Distinguish property reads/writes, getter/setter dependencies, signal dependencies, and invokable/slot calls. Do not infer a method call to the getter from every binding; the property dependency is the core fact. Overloads, typedefs, conditional compilation and inherited members need explicit handling or an ambiguity diagnostic. + +Literal `setContextProperty("backend", &backend)` produces a scoped exposure fact when the provider type and engine/component path can be traced. Supported `setContextObject(&provider)` also supplies its declared properties to that specific context. Preserve explicit-property precedence over context-object properties and known parent/child context mappings; unknown creation context stays conditional. Neither form injects a global provider into every QML file. [QQmlContext](https://doc.qt.io/qt-6.8/qqmlcontext.html) defines these context boundaries and precedence. Context properties are invisible to Qt's ahead-of-time QML tooling, as [Qt documents](https://doc.qt.io/qt-6.8/qtqml-cppintegration-contextproperties.html); source analysis also requires bounded application-context evidence. + +Collect literal initial-property maps from `QQmlApplicationEngine::setInitialProperties` and `QQmlComponent::createWithInitialProperties`, retaining the supplied member names, provider values and owning loader/component. Resolve an object-valued provider's exposed surface only when its C++ type and the QML target property are established. These are initial property assignments, not context-global names. Preserve unresolved values and conditional ordering. Arbitrary interprocedural context-object/initial-property dataflow remains deferred. See [engine initial properties](https://doc.qt.io/qt-6.8/qqmlapplicationengine.html#setInitialProperties) and [component creation](https://doc.qt.io/qt-6.8/qqmlcomponent.html#createWithInitialProperties). + +### Native Qt C++ signals, slots and connections + +REQ-QML-016 extends ordinary C++ analysis even when no QML files are present. Retain `signals`/`Q_SIGNALS` sections, `Q_SIGNAL`, access-qualified slot sections, `Q_SLOTS`/`Q_SLOT`, full member signatures and declaration ownership. Collect `emit`/`Q_EMIT` sites, and classify a resolved invocation of a declared signal as emission even if the optional keyword is absent. A direct call to a slot remains an ordinary C++ call. A signal emission refers to its signal declaration; it does not invent immediate calls to connected receivers. Qt's [signals and slots documentation](https://doc.qt.io/qt-6.8/signalsandslots.html) distinguishes normal direct C++ access from meta-object delivery, which can reach private slots. Preserve access and invocation mechanism separately; do not expose every private slot as a QML API. + +Recognize `QObject::connect` and receiver/bare `connect` only where syntax/type evidence identifies the Qt API. Support member pointers, signal-to-signal connections, `qOverload`/supported `static_cast` selectors, lambda/functor callbacks with or without a context object, and string-based `SIGNAL`/`SLOT` forms. Resolve signatures against the sender/receiver type and visible declarations, rather than a global label. A pointer-form receiver can be a compatible ordinary member; a string-form private slot is a meta-object endpoint. A lambda has its own source-owned callable identity, and calls in its body stay ordinary calls from that callable. Preserve explicit `disconnect` endpoints or a traceable connection handle as separate source facts. [QObject's connection overloads](https://doc.qt.io/qt-6.8/qobject.html#connect) define these forms; source analysis records a declaration of intent, not successful runtime registration. + +Store the declared `Qt::ConnectionType` and literal flags, including default `AutoConnection`, in connection metadata. Keep `DirectConnection`, `QueuedConnection`, `BlockingQueuedConnection`, `UniqueConnection` and `SingleShotConnection` distinct when present. `AutoConnection` depends on runtime thread affinity at emission; static source must not choose a delivery mode or claim thread safety/order. Flag expressions outside the supported literal subset remain unresolved. `UniqueConnection` has a documented limitation for lambda/functor targets; report that limitation without predicting execution. See [connection types](https://doc.qt.io/qt-6.8/qt.html#ConnectionType-enum) and [QObject](https://doc.qt.io/qt-6.8/qobject.html#connect). + +### C++ consumption of QML object APIs + +REQ-QML-017 follows a bounded source-backed chain: literal loader/module reference, the same engine or component handle, selected root/created object, optional named child, then a literal property/method/signal name. Collect `QQmlApplicationEngine::load`/`loadFromModule`, `QQmlComponent` URL construction/loading and `create`/`createWithInitialProperties`, and `rootObjects` access. `loadFromModule` is a Qt 6.5+ API and resolves through the declared module index. Component-relative URLs use the engine base URL; absent source-backed configuration, do not resolve them relative to the C++ source directory. Record possible loaded/created component types without asserting that creation succeeded or returns a live object. See [QQmlApplicationEngine](https://doc.qt.io/qt-6.8/qqmlapplicationengine.html) and [QQmlComponent](https://doc.qt.io/qt-6.8/qqmlcomponent.html). + +The Qt Quick view profile also supports literal `QQuickView::setSource` paired with the same view's `rootObject`, engine/root context and initial-property declarations. Use the same URL/member/provenance rules. `QQuickView::loadFromModule` is available only from Qt 6.7, unlike the engine's Qt 6.5 method; do not conflate their version gates. Qt documents these entry points in [QQuickView](https://doc.qt.io/qt-6.8/qquickview.html). `QQuickWidget` and custom loader wrappers need a separately reviewed profile. + +Initially support local assignments, typed handles and explicit receiver chains within a documented function scope. Reassignment, escaping pointers, multiple possible loaders/root objects, computed names or dynamic creation stop a unique resolution. A root selection must be tied to the same engine and a uniquely supported load; a `QObject*` declaration alone is insufficient. Map literal QML `objectName` values to potential child objects under that component, respecting established object-tree/type/search-option evidence. A QML `id` is not an `objectName` lookup key. Duplicate names and delegate/runtime-created descendants remain ambiguous; the QML visual hierarchy alone does not establish a particular runtime QObject child tree. + +With that provenance, resolve supported literal `QObject::property`/`setProperty`, `QQmlProperty` read/write, and `QMetaObject::invokeMethod` to QML properties/functions/signals. Preserve read/write direction and the API variant; do not simulate binding effects or property values. Retain invocation signature and declared connection type where supplied. Unknown signatures, missing members, computed names and dynamic properties keep diagnostics. A meta-object invocation is an access/dispatch dependency; it is not a proven synchronous direct call. QML-declared signals can connect to C++ slots/callables through the same connection-site contract, using known meta-object signatures. C++-to-QML handlers use the resolved exposed signal/object contract in the other direction. Qt documents these surfaces in [C++ interaction with QML objects](https://doc.qt.io/qt-6.8/qtqml-cppintegration-interactqmlfromcpp.html), [QQmlProperty](https://doc.qt.io/qt-6.8/qqmlproperty.html), and [QMetaObject invocation](https://doc.qt.io/qt-6.8/qmetaobject.html#invokeMethod). + +### Build declarations and resources + +Collect literal target/module facts from `qt_add_qml_module`/`qt6_add_qml_module`: target, URI, VERSION, PAST_MAJOR_VERSIONS, SOURCES, QML_FILES, RESOURCES, imports/dependencies, resource prefix, output/import directory, typeinfo and relevant source properties. CMake's command introduced in Qt 6.2 integrates QML, C++ and generated metadata; Qt 6.8's QTP0004 can produce additional `qmldir` files for subdirectories. Store policy/configuration evidence rather than assuming one generated layout. See [qt_add_qml_module](https://doc.qt.io/qt-6.8/qt-add-qml-module.html). + +The accepted reader handles literal top-level Qt module arguments. It does not evaluate `set`, list expansion, includes, conditions, generator expressions, function scopes, toolchain inputs or environment substitutions; unsupported module-affecting syntax retains incomplete evidence and blocks authoritative publication. Existing generated `qmldir`/`.qmltypes` can contribute only when already admitted by the caller, with generated provenance retained. No configure/build is started to obtain them. + +For qmake, accepted `.pro`/`.pri` files contribute literal assignments for module/version/config, SOURCES/HEADERS, import paths and RESOURCES. Includes, conditions and expansions are not evaluated. A literal major-only registration has default minor zero with explicit provenance; it does not establish arbitrary later minor availability. This is metadata extraction, not an implementation of qmake. Prefer exact source/generated declarations over directory-name guesses. + +Parse `.qrc` XML without DTD/entity expansion. Map each file's path relative to the `.qrc`, runtime prefix and alias, and any locale/selector qualification. Keep filesystem paths and resource URLs distinct. Normalize `:/...` and `qrc:/...` to a common resource key while preserving source spelling. A `.qrc` alias can differ from the source basename; CMake's QML resource prefix also affects URLs. See [Qt resource system](https://doc.qt.io/qt-6.8/resources.html). + +Accepted C++ loaders resolve literal module/resource/file URLs and supported QUrl wrappers only with established source/context evidence. A resolved literal gives a possible load/reference dependency, not proof of object creation. General QML `Loader.source`/`Qt.createComponent` resource dependency resolution is a later profile; current QML expression analysis does not establish their runtime-created object type. Variable URLs, `Qt.createQmlObject`, concatenated QML strings, remote URLs and runtime registration remain unresolved. See [dynamic object creation](https://doc.qt.io/qt-6.8/qtqml-javascript-dynamicobjectcreation.html). + +## Incremental behavior and consumers + +INC-QML-06 bypasses syntax cache for QML/Qt metadata and native syntax in a Qt context, preserving plain generic C++ caching. Provider/source/script and parser/import/admission changes conservatively re-extract already accepted code before reconciliation. Failed/partial/omitted contributions and script/join failures reject publication even under force; prior graph, manifest and report outputs remain intact. Unsupported subfolder scoped-ID rebases reject with `QML_ROOT_MISMATCH`. Earlier scan/stat bookkeeping lies outside this publication boundary. Dependency-directed caching is deferred optimization. + +Source facts and project-context resolution remain separate owners. The hash-only analysis checkpoint covers installed parser/package and Qt fact/policy versions, ordered import roots and accepted-corpus/ignore configuration. CLI/watch commit it only after successful graph and manifest publication. A warm run cannot reuse a decision whose provider or compatibility inputs changed. + +The implemented fallback rebuilds accepted code and regenerates Qt/QML-derived resolution after provider/configuration changes. Deletion, rename, changed version, duplicate-provider introduction and removed registrations clean obsolete edges. QML changes also refresh unchanged C++ consumers; native event changes require no modified QML. A later dependency-directed optimization may reuse more source facts only after proving equivalent mutation/configuration output and failure behavior. + +Refresh activation uses exact filenames, accepted inputs and persisted Qt context, including last-provider deletion. A `qmldir`-only, CMake-only, `.qrc`-only or C++-header-only update resubmits affected accepted inputs for fresh joins rather than relying on changed-file suffix registration alone. Ownership filtering prevents borrowed old inferred edges from surviving beside fresh resolution. + +Discovery, code-only mode, direct single-file extraction, CLI update, watch batching and ignore/symlink containment use the same filename classification policy: `.qml`, `.qmltypes`, `.qrc`, `.pro`, `.pri`, `.cmake` and exact `qmldir`/`CMakeLists.txt`; `.ui.qml` preserves its compound identity. Metadata references cannot escape accepted root/corpus boundaries or fetch an HTTP import. External paths retain bounded reasons without publishing absolute machine paths. + +INC-QML-07 consumers cover query/node detail/explain/path/affected, installed MCP, coverage/report/HTML views and selected semantic exports. Source names, roles, locations, direction and confidence remain visible; event/meta-object/reactive mechanisms remain distinct from direct calls. Wiki and presentation formats have explicit omissions in [EXPORT_MATRIX.md](EXPORT_MATRIX.md). Assistant changes originate in authoritative fragments and are regenerated with frozen baseline checks. Revision-specific hosted evidence is recorded in [VALIDATION.md](VALIDATION.md); later general HTML changes and their local/platform/browser evidence are historical and deferred for this contribution. + +## Support matrix and release gates + +The first column lists implemented bounded source-analysis scope. Individual acceptance evidence and revision-specific platform limits remain in [traceability](../../tests/TRACEABILITY.md); this matrix does not claim Qt engine/runtime equivalence. Export-specific preservation and omissions are in [EXPORT_MATRIX.md](EXPORT_MATRIX.md). + +| Area | First accepted scope | Later/deferred scope | +| --- | --- | --- | +| `.qml` | File/root type, objects, IDs, members, source ranges, imports, parser diagnostics | Unknown future syntax, annotations beyond the tested parser profile | +| `.ui.qml` | Normal QML declarations and aliases; distinct compound filename | Complete Designer validation and editing | +| `qmldir` | Module URI, literal exports/versions, singleton/internal, JS/typeinfo, imports/dependencies | Runtime plugin availability, remote modules | +| `.qmltypes` | Bounded tooling exports/prototypes/members/flags/revisions with generated provenance and source conflicts | Unknown schema variants, runtime-provider synthesis and loaded-plugin equivalence | +| Imports | Repository-local modules/directories/JS, explicit roots, version/alias handling | Ambient SDK search, package installation, runtime engine path changes | +| Scope | Local lexical scope, component IDs, known members, named inline components | Full runtime instance hierarchy, arbitrary delegates/model-role typing | +| Bindings | Scoped alias targets, statically identified property reads, explicit conditional reads | Precise runtime dependency sets, evaluation order, cycles requiring execution | +| QML signals | Local/known exposed signals, static supported handlers/`Connections`, property-change signals | Attached providers, dynamic target/connect/disconnect lifetime modeling | +| JavaScript | Implemented embedded sites, accepted classic/ESM resource overlays and original locations | Arbitrary computed dispatch, re-export evaluation, scripts/QML strings execution | +| Qt C++ | Explicit supported meta-object members plus exact module/literal registration evidence | Arbitrary macro wrappers, compiler-level templates/overloads, full context dataflow | +| Qt C++ events | Source signal/slot, emission, typed/signature connect, lambda/overload and disconnect profiles | Proving registration success, runtime delivery order, thread affinity/lifetimes, arbitrary callback wrappers | +| C++ consumes QML | Literal engine/component/view loaders, roots/objectName lookup and declared member access | Dynamic URLs/names, arbitrary pointer/dataflow, duplicate runtime object selection, custom loaders and QQuickWidget | +| Context and initial properties | Literal properties, typed context objects and scoped initial-property maps | Arbitrary interprocedural providers, runtime replacements and full context hierarchy reconstruction | +| CMake/qmake | Accepted literal metadata, exact source membership and admitted generated files | Full configure/build evaluation, includes/expansion and toolchain/platform conditional resolution | +| Resources | Entity-safe QRC prefixes/aliases, explicit CMake mappings and accepted C++ loader URLs | Binary `.rcc`, runtime registration, locale/file selectors, general QML dynamic-loader object typing | +| Qt 6 | Qt 6.5/6.8 source fixtures with bounded bridge/metadata support | Additional release profiles only after fixtures and tooling checks | +| Qt 5.15 | Planned separate legacy fixtures for manual/qmake/literal registration | Full historical Qt QML behavior and unsupported old syntax | +| Incremental | Conservative accepted-corpus provider/config refresh, stale-edge cleanup and fail-before-publication guards | Dependency-directed optimization and new mutation profiles | +| Consumers | Local query/explain/path/affected/MCP, coverage/HTML and documented semantic export transports | Final INC-QML-07 hosted proof, live database system checks and omitted presentation facts | + +Before advertising the first Qt/QML release, require: + +1. A parser decision record with reproducible install, ABI, grammar, license and syntax results on declared platforms. +2. Positive fixtures and false-positive tests for duplicate IDs, names/modules, alias/version scope, grouped/attached syntax and unrelated C++ methods. +3. A small mixed QML/JS/C++ project whose expected dependencies are independently specified, including missing modules and deliberate ambiguity. +4. Cold, warm, sequential, parallel, relocated-root, full-update and metadata-only incremental outputs that agree after canonical sorting. +5. Regression tests proving unrelated language resolution, install without the extra, schema validation, graph build and essential consumers still work. +6. Explicit coverage/diagnostics documentation and an upstream review of any builder interop exception or cache/registry contract change. +7. Native Qt event fixtures distinguishing direct slot calls, emissions, private-slot/string connections, ordinary pointer-form receivers, lambdas, overload selectors and connection flags, including false positives from unrelated `connect` names. +8. Bidirectional fixtures with engine/component/view provenance, literal context/initial properties, objectName versus id, multiple roots/duplicate names, property read/write/invoke and QML-signal-to-C++ connections; validate QML-only and C++-only incremental edits through consumers. + +## Decisions and alternatives + +- **D1: Extend the existing pipeline.** New extractor/metadata/resolver modules preserve upstream reviewability. A separate Qt-only Graphify rewrite would duplicate orchestration and fragment consumers. +- **D2: Parser choice follows evidence.** The optional language-pack adapter is accepted from the recorded spike and revision-specific hosted lanes through INC-QML-07. Final implementation-head proof is recorded in VALIDATION.md. A smaller maintained binding is an alternative if coverage or package footprint later fails. Preserve optional core installation. +- **D3: Source facts precede resolution.** Resolve owned declarations using a per-run project index. Qt syntax cache bypass plus the analysis compatibility checkpoint provides the current safe policy. Future source caching must preserve parser/fact/config invalidation, explicit worker inputs and fresh project joins. +- **D4: Precision requires scope and exposure evidence.** Do not resolve by global short-name matching or combine all language families. Qt bridging is a narrow extension with independent evidence and consumer tests. +- **D5: Preserve the stable public schema.** Namespaced optional metadata and compatible relations land first. Any new relation or graph storage mode requires a separate consumer/migration design. +- **D6: Ambiguity is a result.** Store a reason/candidate summary and avoid inventing runtime behavior. Optional Qt validation can strengthen the test corpus without becoming an execution dependency. +- **D7: Metadata is read, not executed.** Explicit import/build roots and supplied generated files provide reproducibility. Full build-system evaluation and dynamic runtime resolution remain separate future work. +- **D8: Qt events and both object API directions retain their mechanisms.** Emission/connection/load/access sites preserve source ownership, roles and evidence. Ordinary calls remain ordinary calls; event delivery and meta-object dispatch are dependencies with declared semantics and runtime limits. + + + + +## INC-QML-04 implemented boundary + +Known Qt annotations are normalized in place before generic C++ extraction. +Byte length, CRLF and original offsets stay unchanged. The existing C++/CLI +normalization and generic C++ extractor still own canonical declarations. Qt +class/member/property/registration/event/access facts are separate source-owned +`metadata.qt.contract_version=1` overlays. Their joins run after final canonical +ID and path remapping; they never reconstruct a target from its label or ID recipe. + +- **D9: Canonical declarations precede Qt joins.** A valid inline Qt annotation + previously made the generic parser invent a `public()` method and omit the real + invokable. Byte-preserving normalization fixes the parser input; accepting a + guessed replacement ID would leave an invalid endpoint. AST cache schema 5 + invalidates earlier generic declarations while semantic cache entries survive. + +Registered QML types and scoped supplied instances use independent provider +proofs. Class/member identity, exact source scope and endpoint evidence authorize +cross-family links in the builder. Source-owned occurrence and endpoint-role nodes +retain signal-to-signal subscriptions, emissions, disconnects, reflective access +and repeated dependencies in the default simple graph. Versioned Qt edges retain +producer direction through undirected JSON export/reload. + +INC-QML-05 activated the metadata-index foundation for accepted literal CMake/qmake, +QRC and type-description input. INC-QML-06 verified conservative provider/configuration +refresh and deletion cleanup. Native member revisions, foreign/extended/attached +providers and compiler conversions remain unsupported evidence in this profile. + + +### D10 — Conservative Qt refresh and explicit syntax policy + +Use a full refresh of already accepted code when Qt inputs or analysis compatibility +change. It trades extraction cost for equivalent cold/warm/manual/watch output and +last-provider cleanup. Qt contexts reparse native syntax; plain C++ retains baseline +portable caching. No prior cache is deleted to recover a failed Qt analysis. A +future dependency-directed optimization must prove the same mutation/configuration +contracts before replacing this policy. The writer commits the hash-only Qt state +after the graph and manifest, so a failed publication cannot mark the input current. + +### D11 — Typed facts survive consumers with explicit format limits + +INC-QML-07 projects bounded decoded semantic fields into search, retains logical +source direction in JSON/query/MCP, promotes source-owned dependency sites for +affected traversal, and displays event/access mechanisms separately from calls. +Coverage counts unresolved sites rather than treating a graph with few ambiguous +edges as fully resolved. [EXPORT_MATRIX.md](EXPORT_MATRIX.md) distinguishes +semantic transports from presentation views and records live database gaps. +Generated assistant AST guidance inspects ordered configuration read-only and +passes it explicitly to extraction; final checkpoint ownership stays with the +successful graph publisher. This implementation is locally tested; final hosted +source/artifact proof must match the INC-QML-07 reviewed head. + +D10 adoption follow-up: a syntax-correction upgrade with the same package version +must invalidate stale AST results and Qt analysis stamps. Cache schema/policy +epoch ownership stays with the existing cache/refresh modules; valid unrelated +plain C++ caching is preserved. Byte-preserving syntax recovery is a pure parser +helper under the original source owner, not preprocessing, runtime execution or +an alternate extraction pipeline. Obsolete AST-schema artifacts may be retired +by the upstream cache migration; this is not deletion of accepted graph data or +compatible caches to conceal a failed candidate. + +## D12 — Recover HTML communities as local presentation state + +Disposition: **Deferred for the upstream contribution** under REQ-QML-019 and +the generic viewer portions of INC-QML-09/13/16. The following decision describes +the frozen fork's community recovery, aggregate-count and selection policy. +The contribution restores upstream generic HTML behavior while retaining Qt +metadata, tooltips and the documented Qt-only aggregate omission marker. + +Large unclustered graphs need a complete partition and usable names before +community aggregation. Reuse the existing deterministic clustering/hub labeling +interfaces within HTML view preparation, preserving canonical graph and analysis +artifacts. Deriving presentation membership avoids requiring a model or a source +rescan merely to inspect an accepted graph. A failed or unhelpful bounded view +retains previous HTML and reports its actual result. + +Initial selection activates all exported view nodes and edges, with Select All +checked. Large graphs retain complete labeled community aggregation and its +supported cap rather than forcing raw full-source rendering. INC-QML-16 removes +the Overview control and its selection/ranking state; community filters, search +and Select All/None remain the selection interfaces. Initial selection is +explicit presentation policy rather than saved personal view state. +Structural clustering supports navigation; versioned architecture/design +documents describe intentional ownership. Earlier checked-default/Overview AC04 +proof belongs to that prior revision. The changed AC04 was locally verified at +the recorded emitted-script and reviewed installed-artifact boundaries; native browser, +device, other-platform and new hosted checks remain unverified. Remaining parser/provider +adoption contracts retain their scope. + +## D13 — Complete definitions and exact provenance authorize native ownership + +Forward declarations and complete definitions are distinct accepted source facts. +Retain their IDs and spans while separating declaration evidence from complete +class authority. An additive `is_definition` field records AST body presence; +native out-of-line binding and event lookup require a unique accepted complete +class. Two distinct complete definitions remain ambiguous. Exact canonical +method `definition_file`/`definition_location` and class containment authorize +header/implementation ownership; a global class or member name does not. + +Complete-body authority survives reuse only when exact producer source file and +original span accompany accepted-context records. Fresh and borrowed +representations of one canonical body count once; losing provenance cannot make +them competing definitions. Preserve borrowed input dictionaries and genuine +ambiguity between distinct bodies. Current source tests cover the corrected +context path; earlier full-corpus artifact results did not cover this regression. + +Deleting/merging forward declarations would lose source identity. Accepting every +same-name class or broadly matching methods by name would create false owners. +Reusing complete-definition and canonical-provenance evidence corrects ownership +without replacing the generic C++ extractor, remapping IDs or inventing runtime +relationships. Native source enrichment, mapping and event indexes retain their +focused owners; missing or unsupported evidence remains visibly unresolved. + +INC-QML-10 introduces Qt policy epoch 3 for same-package refresh while retaining +AST cache schema 6 and existing persistence owners. Source ownership/context and +upgrade tests, final broad suite and reviewed installed-artifact checks pass locally. +The body-key fix is internal transport, not a new persisted fact contract. Earlier profile passes remain +revision-specific. Isolated nodes can represent valid unsupported or unproven +relationships and are not a requirement to invent connectivity. D12 presentation +policy and unfinished INC-QML-08 adoption work remain separate. + +INC-QML-11 is a planned event-lookup follow-up: recursive inheritance compatibility +currently exceeds the immediate-base search used to find a member endpoint. +Extending ancestor lookup must retain D13's complete-class authority and exact +member identities, bounded traversal and genuine ambiguity. Its grandparent +signal cases are not verified by INC-QML-10 or the initial profile; no arbitrary +inherited-member or runtime support is claimed. + +The INC-QML-12 baseline identified the generic constructor canonicalization gap: +an accepted out-of-line constructor can still be contained by an implicit class +placeholder instead of its complete header class. D13's native guard must retain +unresolved ownership until the generic producer/canonicalization supplies the +correct parent evidence. The implemented constructor seam now supplies bounded +class/prototype/signature proof; source containment remains independent. Preserve +constructor IDs/provenance and reject conflicting namespace/class/parent cases. +Its separate proof does not close INC-QML-11. + +## D14 — Source containment is independent of native class authority + +Observed Qt source occurrences retain links to their accepted generic callables +even when Qt class evidence is absent. This connects source facts without +guessing a class, QObject role, QML exposure or runtime target. Local class +syntax can retain its enclosing callable while its canonical native class ID +remains unavailable. Exact source/provenance and ambiguity guards still apply. +An occurrence without a proven callable can retain a link to its uniquely accepted +source file. This is file membership, not a callable or class identity; its +unresolved semantic metadata remains unchanged. + +Generic constructor prototypes and class authority belong to the C++ producer +and declaration/definition canonicalizer, not a Qt name-based fallback. A focused +helper carries bounded original-source identity and signature proof; native +consumers reject unbound constructors without removing their source evidence. +Existing callable IDs and persistence owners remain authoritative. AST schema 7 +and Qt policy 5 retire stale same-version facts. Original INC-QML-10 policy-3/ +schema-6 evidence is historical, not proof of these additional cases. + +The complete generic inspector correction remains historical under deferred +REQ-QML-019. Internal/external source-edge counts remain a REQ-QML-020 membership +prerequisite; connected-community labels and isolate explanations are deferred. +D14's source containment and the membership projection remain in the contribution. +HTML aggregation is presentation state: internal source links and distinct +external communities are different measurements. Display their actual counts +without inventing topology, suppressing isolates or changing source data. +Metadata membership projection belongs to the separate REQ-QML-020 contract; +internal-only metadata communities do not establish a missing runtime import. +INC-QML-15 separately covers existing constructor-overload identity collisions +and generic full/update location parity; the bounded singleton proof does not +establish that broader profile. + +## D15 — Project membership is independent of component use + +Literal accepted CMake/qmake source lists and resource aliases prove packaging +membership even when no QML source instantiates the component. Project that +evidence through independent source-owned resolution sites: metadata declaration +to site containment, then a resolved site to the unique accepted file or QML +component by `references`. Retain declaration spans, scope and provenance. +This relationship establishes neither a component instance nor runtime loading, +signal delivery or source dependency beyond the declared membership. + +The per-run project index remains the lookup owner. The projection consumes its +accepted corpus and does not read target files, execute build scripts, widen +discovery or alter module/resource lookup behavior. Missing, conflicting, +conditional, generated or foreign evidence remains unresolved without a target +edge. Exact endpoint roles and original literal transport remain authoritative; +labels and HTML proximity cannot supply proof. Borrowed source dictionaries stay +immutable, including when a refreshed decision replaces old derived context. + +`qt_project_membership.py` owns projection; `qt_qml_pipeline.py` owns invocation +and failure isolation. Existing CLI/watch writers retain publication ordering +and prior-output failure policy. Its accepted-input comparison uses canonical +contained path identity, including short-path and parent-directory aliases, +while stored lexical provenance remains immutable. Canonical identity cannot +authorize a foreign declaration or expand the accepted corpus. INC-QML-39 +corrects this comparison without a new index, reader or persistence owner. +The initial projection's Qt policy 6 invalidated prior derived graphs at the same +package version while preserving AST schema 7. The INC-QML-39 checkpoint retains +policy 18/schema 12; D23 subsequently advances policy 19 for facade repair while +preserving producer fact contracts and AST schema 12. +INC-QML-14 implements this bounded literal membership profile. Exact local +producer, lifecycle, consumer and installed-artifact evidence belongs to +requirements, plan and +traceability. HTML clustering consumes persisted edges and adds no synthetic +membership. Exact overload identity remains the separate INC-QML-15 contract. + +## D16 — Middle-button input owns only the temporary camera + +Disposition: **Deferred for the upstream contribution**. Historical status: +**Locally verified at the recorded emitted-script and reviewed installed-artifact +boundaries** under REQ-QML-021-AC01–AC03 and changed REQ-QML-019-AC04. These +passes describe the frozen fork's INC-QML-16 feature. Native +browser/device and other-platform behavior remains unverified. A held mouse +middle button pans the existing 2D network camera. +Screen movement `(dx, dy)` produces world movement `(-dx / scale, -dy / scale)` +from the current view, preserving the current scale and disabling camera animation +for that movement. Reading the scale on each move preserves native wheel zoom +between movements. Source and aggregate views share this input contract. + +The [frozen camera helper](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/exporters/html_navigation.py) +defines `MIDDLE_PAN_SCRIPT`, a plain JavaScript IIFE over +the existing `container` and `network`; the HTML exporter injects it once after +network construction and initial physics setup. Mouse middle pointer input is +handled separately from left/right/touch and wheel behavior. Native middle-button +autoscroll and active text-selection gestures are suppressed only for this input. +The helper calls the existing camera API; it changes no node positions, selection, +physics options, filter state, datasets or canonical graph payload. + +Container pointer capture is guarded. Window pointer listeners provide an outside- +container/capture-failure fallback. Matching release/cancel, lost middle-button +state, lost capture, window blur and page hide end the gesture. Finite pointer and +camera values, positive scale and finite derived movement are required; invalid +camera/API state aborts safely without a later jump. Cleanup restores the previous +cursor/user-selection state before attempting capture release. + +This is temporary browser camera state with no persistence, account access, +network fetch, analyzed-code execution or new SDK dependency. Existing renderer, +export and atomic-write owners retain their failure boundaries; invalid gesture +state adds no graph diagnostic or writer operation. Qt policy 6, AST schema 7 and +source fact schemas remain unchanged. The new helper/tests stay below 300 lines; +the narrow HTML injection/removal stays within the measured legacy viewer ceiling. +Exact production emitted-script, rejection/coexistence and reviewed installed- +artifact evidence belongs to traceability/validation. The recorded navigation/selection +tests pass 60 cases, including 24 independent axis/scale/view variants. The source, +wheel and isolated installation contain 156 byte-equal Python payloads. No browser +visual or native-device proof is inferred from the recorded camera/network harness. + +## D17 — Source authority precedes cross-language joins + +INC-QML-17–20 retain one run-owned index and the existing scratch/publication +boundary. C++ reflection uses the receiving QObject's accepted members, not QML +lexical fallback. Child lookup requires accepted construction-owner evidence and +search depth; explicit or possible parenting changes retain uncertainty. This is +separate from QQuickItem's visual parent and from runtime ownership claims. + +Loads, contexts and handles require source declaration identity and supported +lexical lifetime. Native aliases require the declaration visible at the type's +own source location. Literal constructor/loadUrl routes reuse existing component +admission. Matching text alone grants no object, type or provider authority. +Unknown or conflicting evidence remains unresolved rather than selecting a +convenient global or enclosing declaration. The alternative of component-wide or +function-wide name matching produced the reproducible wrong targets in A10–A13. + +The runtime remains Python and does not execute Qt/QML input. Additive source +metadata retains original spans and existing IDs; analysis policy epochs retire +stale derived relationships at unchanged package versions. AST schema 7 remains +under its existing owner because accepted Qt inputs bypass that syntax cache. +Requirements and the correction matrix define acceptance; validation and +traceability record executed source, update, consumer and reviewed artifact proof. + +## Literal loader integration and remaining authority gaps + +INC-QML-20 extends the existing native access collector, without a second runtime +or writer. Source declaration identity owns each loader and component-to-engine +association; the accepted resource/module index supplies the QML target. URL +wrappers and overloads require bounded SDK authority. Policy 10 invalidates old +derived overlays; source AST schema 7 and graph relationship contracts are intact. + +At the INC-QML-20 checkpoint INC-QML-21/22 were distinct producer/API +authority corrections and INC-QML-23 owned the shared Windows persistence +failure. The later decisions and correction seams below supersede those gaps. Local correction acceptance and repository gate status are separate +evidence boundaries, recorded in validation and traceability. + +## D18 — Exact qualified native producer identity + +INC-QML-21 changes the authoritative generic C++ producer before Qt mapping. +Global class/member IDs retain their previous contract; qualified classes use a +digest of the exact case-sensitive namespace/class spelling before ordinary ID +normalization. Members inherit the accepted class identity. Complete source body, +original span, sibling family and unique signature evidence govern header/source +joins; forward declarations cannot replace a complete body. Literal using-namespace +scope is bounded and fails closed on overflow. Label-only remapping is rejected. + +The alternative of reconstructing qualified identities in a Qt resolver leaves +generic facts, raw calls and consumers inconsistent. Producer ownership instead +preserves one identity through facade, native overlays, graph and reload. Qualified +IDs intentionally change; consumers must rebuild rather than mix old and new IDs. +Final AST schema 9 and Qt policy 12 retire older cached/derived facts. The same +schema also includes INC-QML-27's explicit handler parameter authority. Generic +legacy IDs and graph schema remain unchanged. A downgrade requires a fresh rebuild +with its matching analyzer; cached output is not a migration oracle. + +## D19 — Coordinated acceptance product publication + +INC-QML-23 preserves individual replacement rollback. INC-QML-25 adds one caller- +owned publication transaction around graph, root, manifest, Qt stamp and optional +cluster report/labels. Existing serializers prepare owned staged files; accepted +products advance only after preparation succeeds. Recoverable publication failures +restore prior durable products, while a second restoration failure preserves disk +snapshots and reports an integrity/recovery diagnostic. + +Successful content-keyed source AST cache entries remain analysis-owned and may +survive rejected graph publication. They do not accept a graph or its checkpoint. +Unchanged products avoid replacement; the transaction rejects a detected competing +writer. This is completion and exception recovery, not multi-file atomicity under +process termination, power loss or uncoordinated OS writers. Recovery guidance and +cleanup outcomes are defined in the error catalog. No second runtime or persistence +framework is introduced. + +## Native notification and refresh ownership + +INC-QML-22/24 require source-position SDK type authority, including rejection of +same-spelled forward declarations, before admitting reflection/loader semantics. +INC-QML-26 removes only unreferenced source-less AST placeholders absent from a +successful complete Qt corpus refresh; unchanged semantic and live references +remain owned by their origins. INC-QML-27 maps accepted property notifications to +real native signals and separates explicit formal parameters from legacy injection. +The [chapter review](EXPOSURE_CHAPTER_REVIEW.md) and API inventory state exclusions. +Bounded correctness evidence remains separate from wider adoption and hosted proof. + + +## D20 — Constructor signatures authorize identity and joins + +INC-QML-15 assigns constructor IDs in the generic C++ producer before shared +node deduplication. Every accepted constructor, including a singleton, uses its +canonical class and versioned exact signature digest. Bounded builtin types, +self-class types and evidenced literal QObject forms are admitted; names/defaults +and value-level CV are excluded from type identity, while pointee/reference CV +and pointer depth remain significant. Unsupported signatures keep distinct +source occurrence IDs and cannot authorize a native declaration/body join. + +The alternative of splitting collapsed nodes in the Qt overlay would leave generic +calls, source locations, cache and consumers inconsistent. `cpp_constructor_binding` +owns one corpus join using original declaration/definition/class evidence. Source +ASTs own bounded includes and QObject shadows; only already admitted dependencies +participate. An angle include that could refer to admitted local source cannot +lend SDK signature authority without a proved include search. No compiler or +build preprocessing runs. + +AST schema 10 and Qt policy 14 intentionally invalidate previous constructor +facts/derived ownership at unchanged package/source versions. Qt corpus updates +refresh automatically under that policy. Existing generic-only graphs need a +full `graphify update .` after upgrading; retained cache or watch state is not an +ID migration oracle. A downgrade requires a fresh rebuild with its matching +analyzer. Graph serialization schema and unrelated language IDs remain unchanged. +Installed artifact and final gate evidence are recorded separately in validation. + +## D21 — Declared types lend APIs, not runtime object identity + +INC-QML-08b extends context exposure with bounded source-owned factory/member +expressions and child-service API chains. The C++ producer owns original type +spelling, position and declarator shape; one analysis-run index validates borrowed +canonical class/member/accessor evidence. The expression's lexical root declaration +and its engine/component association remain distinct from the returned API type. + +Executing factories/getters or guessing by a global class label would create +runtime authority that source analysis cannot establish. Instead, each accepted +pointer/lvalue-reference hop lends a declared API only. Original CV remains +inspectable; allocation, QVariant conversion, const-correct invocation, lifetime +and runtime alias identity are excluded. Unsupported/conditional shapes grant +no target. Derived subscriptions keep source occurrence, native signal and QML +handler identities distinct through reload and consumer revalidation. + +The run-owned bridge and provider index retain existing extraction/resolution +ownership. Source AST schema and derived policy epochs change with producer +contracts; persistence stays with the existing cohort owner. A separate handler +endpoint avoids inverse graph relations collapsing onto one node pair. Native +implicit parameters must be established before nested subscription lookup; +nearer explicit lexical binders retain priority. No second runtime or framework +is introduced. Version-specific evidence belongs to validation and traceability. + +## D22 — Logical direction requires a complete accepted endpoint pair + +INC-QML-30/31 share one `graph_direction` validator. Valid full markers can reverse +undirected storage but must name the exact stored pair with exact endpoint types. +Directed markers must agree with directed adjacency. Both markers absent retains +legacy physical orientation; partial, foreign, ill-typed or contradictory markers +grant no guessed dependency. Source-owner promotion follows the same rule. + +Trusting each marker independently or replacing malformed markers during reload +would hide corrupted transport. JSON must validate before stripping internal +markers and publishing endpoints. Reload restores direction from serialized +typed edge endpoints only when both internal markers are absent; existing corrupt +markers stay visible for rejection. The consumer and serializer preserve graph +data, source identities and existing persistence ownership. Serialization rejects +before product commit under QT_EXPORT_DIRECTION; force does not bypass integrity. +No graph schema or source-cache epoch change is required. Legacy unmarked graphs +retain their prior behavior; malformed marked graphs now fail conservatively. + +## D23 — Physical input identity precedes source provenance publication + +INC-QML-40 corrects the shared facade's contained/external source decision. +Its existing per-run real-path cache supplies physical identity before source +relativization and endpoint-key remapping. Resolving a supplied alias can establish +the same contained file; relaxing native traversal validation or treating an +external helper's basename fallback as containment would grant false authority. +Original source bytes/spans and literal metadata retain producer ownership. +True external sources keep the existing portable identity policy. No corpus +discovery, content reader, path registry or mutable root is added. + +Existing syntax-cache entries contain facts before this normalization, so warm +replay can apply the correction without an AST schema change. Same-package Qt +policy 19 refreshes previously accepted native/Qt derived products from policy 18, +including a no-change update. AST schema 12 remains unchanged at this checkpoint. Ordinary analysis +stamps and graph-product cohort publication retain ownership; failed refresh +retains prior products and a repaired retry completes the upgrade. Generic-only +older API outputs use an explicit forced rebuild when repair is required rather +than invalidating every language's syntax cache. + +INC-QML-41 independently bounds join-failure diagnostic construction. A failed +filesystem context lookup cannot change a rejected join into a raw backend error +or stop later failure annotations. Safe relative context is informational; +unavailable context grants no source or endpoint authority. Existing failure +codes and writers remain authoritative. Implemented/source/installed/hosted +evidence for both corrections is recorded separately in plan and traceability. + +INC-QML-42 separates physical admission from discovered source naming. Its pure +`source_identity.walked_relative_source` helper receives the canonical root, +already resolved source and the facade's existing cached-realpath dependency. +It preserves distinct accepted walked suffixes, including a leaf/directory alias +below an aliased scan root, and uses physical fallback only without a lexical +root anchor. Physical containment rejects external targets first. No cache, +index, content reader or mutable root belongs to the helper. The facade uses +that single decision for file/symbol IDs, target stem forms and source/definition +provenance; resolving each lookup key independently would collapse owners again. +Policy 20 refreshes prior native/Qt products from 19; syntax schema remains 12. +Generic-only retained outputs require an explicit full/forced rebuild. Source +and installed checkpoint evidence remains separate from hosted acceptance. + +## D24 — Alternate Windows spelling preserves walked source ownership + +INC-QML-45 treats native short and long names of one Windows directory entry as +alternate spellings of one input, while retaining separately discovered junction +and symbolic-link owners. Initial input admission uses the OS long-name lookup +and verifies unchanged physical identity before parser dispatch or syntax-cache +work. Resolving the entire path would collapse source owners; guessing short +names from a tilde or basename cannot establish equivalent input identity. +The runtime remains Python with the standard-library Windows API binding; +ordinary analysis gains no Qt SDK, corpus execution or runtime dependency. + +Microsoft's [GetLongPathNameW contract](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getlongpathnamew) +requires readable path attributes and parent-directory access. A supported existing +file whose identity cannot be established rejects boundedly before extraction; +missing and non-file inputs keep their previous handling. Native lookup output +does not authorize discovery, change raw declarations or grant external/native +endpoint roles. Failure diagnostics and publication guards retain separate owners. +The source helper remains stateless; the facade owns its invocation, per-run +remapping and cache ordering. Source, installed and hosted proof remain distinct. + +## D25 — Manual directory-link discovery is an explicit option + +INC-QML-46 exposes the watch rebuild owner's existing `follow_symlinks` discovery +profile through `graphify update --follow-symlinks`. The CLI owns argument +admission only; discovery, ignore/physical containment, extraction, locking and +product publication stay with their existing owners. Omitting the flag preserves +the default. No saved setting or inferred permission from an older graph enables +directory traversal in a later invocation. + +Following links implicitly or forcing the option through a fixture-only monkeypatch +would hide the real manual interface gap. An explicit option permits cold/manual/ +watch parity under the same admitted profile while retaining conservative default +scope. Unknown flags and multiple roots reject before rebuild; accepted work uses +the existing failure/retention/retry path. No source grammar, Qt policy, AST schema, +cache format, runtime dependency or new persistence boundary changes. + +## D26 — Combined upstream and Qt AST compatibility + +Status: **Accepted; recorded integration profiles verified at 8b6c9d2**. +The current upstream 0.9.76 producer retains Python receiver-shadow and other +new source facts under its schema 5. The Qt/QML branch uses schema 12 for its +accepted producer/callback facts. Integration uses schema 13, retaining both +contracts and making prior namespaces miss rather than admitting stale facts. +The initial INC-QML-49 schema reconciliation retains policy 21's existing +analysis/publication owner; D27 additionally introduces policy 22 for lexical +metadata refresh. Package/source version changes and the new AST namespace +supply the accepted AST refresh boundary. +Semantic caches retain their independent content ownership and are not +invalidated merely by this AST change. No Qt SDK or second runtime is required. + +Keeping schema 5 would discard the established Qt cache contract; retaining 12 +would reuse a published producer identity for additional upstream semantics. +A new schema is explicit, conservative and uses the existing cache cleanup and +recovery path. Old evidence stays bound to its original revision. This decision +changes no requirement identifiers, runtime edge mechanisms or persistence owner. + +## D27 — Physical admission and lexical Qt metadata ownership + +Status: **Accepted; recorded native/POSIX integration profiles verified at 8b6c9d2**. +Physical containment and Windows long-spelling admission remain authoritative for +accepting a source. After admission, a real separately walked contained alias owns +its own Qt/QML source facts, IDs, nested references and original-byte locations. +The shared stateless extractor helper chooses that lexical owner without executing +the corpus or assigning target authority. Literal module/resource/member lookup +retains its existing accepted-identity boundary. + +Canonical-only source facts lose an admitted alias silently and contradict the +established discovery contract. Duplicating files or adding an independent cache +would introduce conflicting owners. Reusing the existing source-identity helper +and publication pipeline preserves the physical guard with one source-fact owner. +Qt source/metadata bypass syntax caching; the combined AST schema remains 13. +Qt policy 22 requires unchanged-input derived refresh of prior canonical-only +products through the existing manual/watch publication owner. Semantic caches, +diagnostic codes and graph format are unchanged. Native junction and POSIX link +proof remain separately identified in traceability. + +The [verified integrated checkpoint](../../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2) +binds D26/D27 source, native and installed evidence to the tested revision. +Static source proof does not establish physical UI/device interaction, runtime +Qt effects or upstream released-package delivery. diff --git a/docs/qt-qml/AUDIT.md b/docs/qt-qml/AUDIT.md new file mode 100644 index 0000000000..438136bf23 --- /dev/null +++ b/docs/qt-qml/AUDIT.md @@ -0,0 +1,223 @@ +# Qt and QML support: upstream audit + +Audited on 2026-10-03. The objective is to extend Graphify's source analysis of Qt and QML projects and contribute the changes upstream. The implementation remains a Python Graphify extension. + +The initial support profile is Qt 6 with both CMake and qmake projects. Qt 5.15 compatibility is a separate future profile that needs its own fixtures and acceptance gates. + +The required analysis includes Qt C++ signals, slots, signal emission and connection semantics, plus bidirectional C++/QML interaction. C++ access to QML-created objects and their properties, methods and signals is part of the scope alongside QML access to exposed C++ objects. These additions are tracked as `REQ-QML-016` and `REQ-QML-017` in [REQUIREMENTS.md](../REQUIREMENTS.md). + +## Baseline and evidence + +The [follow-up audit](FOLLOWUP_AUDIT.md) separately inspects implemented Qt/QML +behavior at `95adbdc` and contribution compliance. This document retains the +foundation baseline; its planned gaps are not current verification results. + +| Item | Audited baseline | +| --- | --- | +| Upstream | [Graphify-Labs/graphify](https://github.com/Graphify-Labs/graphify) | +| Upstream development branch | `v8`, also the imported remote default branch | +| Commit | [`0b60d47e6cd9338c51143f39f35b6c45c8453385`](https://github.com/Graphify-Labs/graphify/commit/0b60d47e6cd9338c51143f39f35b6c45c8453385) | +| Commit subject | `docs(readme): add VS Code one-click and Smithery MCP install` | +| Commit timestamp | `2026-10-02T23:12:01+01:00` | +| Python distribution | `graphifyy`, version `0.9.74`; `pyproject.toml:6-7` | +| Supported Python floor | `>=3.10`; `pyproject.toml:13` | + +Line references below refer to this immutable baseline unless explicitly marked as PR-head evidence. They describe source inspection, not successful execution. At the start of the audit, no `graphify-out/wiki/index.md` or generated graph report was available; source inspection followed the root `AGENTS.md` and upstream `CONTRIBUTING.md` guidance. This audit performed source and public API inspection; setup and runtime verification are recorded separately in [VALIDATION.md](VALIDATION.md). + +The key finding is that upstream already analyzes ordinary C++ and JavaScript, but it has no dedicated QML or Qt meta-object semantics. A `.qml` file is currently omitted from directory detection and collection. Generic C++ support must not be presented as complete Qt support. + +## Existing upstream proposal: review before implementation + +GitHub API inspection on 2026-10-03 found an existing feature request, [issue #1716](https://github.com/Graphify-Labs/graphify/issues/1716), and an existing implementation, [PR #1748](https://github.com/Graphify-Labs/graphify/pull/1748). The PR is a reuse candidate and an upstream coordination reference. These changes have not been merged into the audited `v8` baseline. + +| PR metadata | Observed value | +| --- | --- | +| State | Open, not draft, not merged | +| Title | `feat(extract): add QML support and Qt C++/QML bridge (#1716)` | +| Base branch | `v8` | +| Head commit | `7b38d4c2e2226b1db826a26774c7a5f299b8d62f` | +| Merge base with audited `v8` | `edec9eabeceeae6aa2375eddb3835efa1a32c0a3` | +| Last API-reported update | `2026-07-20T07:04:16Z` | +| Feature diff | 11 files; 1,684 additions and 10 deletions | +| Submitted tests | 17 QML tests and 9 C++/QML bridge tests | +| Checks/reviews visible at inspection | Check-runs endpoint returned zero runs for the head; reviews endpoint returned no reviews. This is not a passing CI result. | + +The feature diff adds `graphify/extractors/qml.py`, QML scan/dispatch/registry wiring, C++ Qt section and property handling, an alias bridge, the parser extra, a fixture and tests. It does not modify watch, cache, metadata ingestion, query/export consumers or CI workflows. The tests and reported smoke test are the contributor's evidence; this project has not executed that PR. + +The current [PR-head manifest](https://github.com/Hamza-Masuadi/graphify/blob/7b38d4c2e2226b1db826a26774c7a5f299b8d62f/pyproject.toml) uses `tree-sitter-qmljs>=0.3.1` in `qml`, `all` and the development dependencies. The older PR description's claim that the parser is unavailable on PyPI is stale. A later [dependency update comment](https://github.com/Graphify-Labs/graphify/pull/1748#issuecomment-4946168692) and current [PyPI metadata](https://pypi.org/project/tree-sitter-qmljs/0.3.1/) show version 0.3.1, an MIT license and Python `>=3.8`; the release currently contains one source distribution and no wheels. Windows installation therefore still needs a tested compilation path or an approved packaging alternative. + +Source inspection identifies the following adaptation work. These findings are static review conclusions and reproduction targets, not a runtime certification of the PR: + +| Risk / gap at PR head | Exact evidence | Required gate before reuse | +| --- | --- | --- | +| Standard named-element syntax is missed | PR `engine.py:1396` matches only a quoted `QML_NAMED_ELEMENT("Alias")`; Qt documents identifier syntax such as `QML_NAMED_ELEMENT(EventDatabase)` in [the macro reference](https://doc.qt.io/qt-6/qqmlintegration-h.html#QML_NAMED_ELEMENT). The submitted alias test mirrors the quoted form. | Regression using valid Qt macro syntax, including foreign/namespace cases and no false matches in comments or strings. | +| Whole-file name tables lose component scopes | PR `qml.py:133-135`, `:250-251`, `:361-379`, `:462-510` uses flat `id_table` and `func_table`. Receiver validation prevents some incorrect member calls but bare calls/references still use the global table. | Repeated IDs/methods in inline components and shadowed JavaScript scopes resolve only inside their valid context. | +| Module information is discarded | PR `qml.py:90-105`, `:174-185`, `:410-425` strips qualified type names to their last segment and does not index import aliases or versions. | URI/version/alias-scoped resolution, duplicate local names and imported directory isolation. | +| Builtin-name suppression hides legitimate local candidates | PR `qml.py:37-78`, `:174-185` suppresses a fixed name list before module lookup. | Locally defined `Button.qml` or an aliased type named like a Qt builtin remains resolvable when the imports authorize it. | +| Partial parses have no QML warning metadata | PR `qml.py:108-123`, `:526` reads `root_node` but never checks `root.has_error` or returns `parse_errors`. | Malformed QML produces actionable bounded diagnostics and preserves persistence guards. | +| Registration bridge relies on global labels | PR `extract.py:2813-2893`; aliases lack a module URI/version in the resolution key. Same-name matching also uses generic stub rewiring. | Prove explicit registration/import compatibility; an unrelated same-name C++ class must not become a QML type. | +| Alias bridge activation misses QML-only batches | PR `extract.py:2933-2940` registers only C/C++ suffixes. | A QML-only incremental change still resolves against unchanged registered C++ types; C++-only changes evict stale QML edges. | +| Declaration tags do not model emissions, connections or reverse access | PR `engine.py:1375-1376`, `:1502-1583` tags signal/slot sections and property accessors. Inspection found no dedicated emission/`QObject::connect` or QML-object-access pass in the PR's `graphify/` source. | Verify signal emission, connection endpoints/mode and C++ access to source-resolved QML objects, rather than treating declaration tags or ordinary calls as the complete relationship. | +| Consumer defaults omit instantiation dependencies | PR `qml.py:256` emits `instantiates`; audited `graphify/affected.py:12-32` does not include it in default traversal. The PR does not modify that consumer. | Queries and affected traversal expose component-instantiation dependencies with original direction and evidence. | +| Macro preprocessing is not lexically scoped | PR `extract.py:1640-1651` globally inserts semicolons after `Q_OBJECT`/`Q_GADGET`, including matching bytes in comments/literals. | Preserve original text/spans and never modify a literal/comment as if it were a declaration. | +| Old C++ extraction edits overlap newer invariants | PR `extract.py:1643` replaces the old generic `extract_cpp()` body; audited upstream `extract.py:2895-2912` now includes C++/CLI normalization and test-macro recovery. PR engine edits also overlap newer C++ method-declaration handling. | Adapt small hunks onto the current baseline and retain all C++ regression coverage; do not replace current functions with older PR versions. | + +The first implementation increment should decide which code and fixtures from PR #1748 to reuse and how to preserve attribution and license notices. Rebase/review that contribution against the current baseline, resolve the packaging and correctness gaps, then extend it through the scoped roadmap. Do not open a duplicate Qt/QML issue or assume a clean merge implies correctness. + +## Existing support and extension points + +| Area | Existing behavior and evidence | Implication | +| --- | --- | --- | +| Pipeline | `ARCHITECTURE.md`; `detect -> extract -> build -> cluster -> report/export` | Extend this pipeline; retain existing CLI and output contracts. | +| Corpus classification | `graphify/detect.py:47`, `:517-551` recognizes code suffixes, named package manifests and extensionless shebang scripts | `.qml`, `.qmltypes`, `.qrc`, `qmldir` and Qt build files need deliberate classification rules. | +| Corpus containment | `graphify/detect.py:1824`; `graphify/extract.py:8871-8930` uses shared ignore rules, noise-directory filtering and resolved-root checks | New metadata walkers must reuse these boundaries, including ignored files and symlinks. | +| Extractor dispatch | `graphify/extract.py:6709`, `:6959-7002`; `graphify/extractors/__init__.py:1-6` | `_DISPATCH` and `_get_extractor` are authoritative. `LANGUAGE_EXTRACTORS` is a registry seed, not the live dispatch mechanism. | +| Per-language modules | `graphify/extractors/base.py:1`; `graphify/extractors/MIGRATION.md`; `tests/test_extractors_registry.py` | Use a focused new module with a facade re-export and registry identity checks; preserve the dependency direction `extract -> extractors`. | +| Generic C++ | `graphify/extract.py:1355-1372`, `:2895-2912`; `graphify/extractors/engine.py:3070-3097` | Classes, structs, enums, functions, calls, quoted includes and enum members already have extraction paths. Extend Qt semantics around those results. | +| C++ headers | `graphify/extract.py:6924-6927`, `:6944-6986` sniffs `.h` for C++ markers after Objective-C detection | Normal QObject subclasses can reach the C++ parser, but Qt macros have no dedicated handling. Preserve C/Objective-C routing. | +| C++ include resolution | `graphify/extract.py:993-1029`; `graphify/extractors/resolution.py:860-871` | Quoted includes resolve relative to the including file. System headers and arbitrary compiler include-search paths do not become complete project metadata. | +| Header/implementation merge | `graphify/extractors/resolution.py:2918-2968`; `tests/test_cpp_objc_cross_file_calls.py`, `tests/test_cpp_method_declarations.py` | Existing declaration/definition identity is useful for Qt bridge targets. Test macros and overloaded methods separately. | +| C++ member calls | `graphify/extract.py:4207-4232`; `cpp_type_table` and per-file raw-call metadata | Explicit scope and `this` evidence can resolve; locally typed receivers are inferred. Ambiguous types are skipped. | +| JavaScript | `.js`/`.mjs`/`.cjs` dispatch and JS/TS symbol-resolution paths in `graphify/extract.py:2034`, `graphify/extractors/resolution.py:2043` | Reusable parsing infrastructure exists. QML imports, `.pragma library`, instance scopes and declarative bindings need their own semantics. | +| Mixed declarative formats | Vue extraction `graphify/extract.py:2548`; XAML extraction `:6450`; XAML corpus context `:7005` | Existing precedents support member nodes, embedded script and framework bridges. They are patterns, not QML implementations. | +| Resolver extension seam | `graphify/resolver_registry.py:29-85`; invocations in `graphify/extract.py:8277`, `:8658-8662` | Add scoped resolution passes after ID remapping and merged indexes. Account for resolver ordering and incremental activation. | +| Provenance and confidence | `graphify/validate.py:4-7`, `:10-86` | Keep `file_type="code"`, source-backed IDs, `source_file`, source locations and `EXTRACTED`/`INFERRED`/`AMBIGUOUS` confidence. Relations are not restricted to an enum by this validator. | +| Searchable attributes | `graphify/serve.py:330-354` flattens only the node `attributes` dictionary for search | A future `metadata.qml`/`metadata.qt` bucket is not automatically indexed. Discoverability requires consumer integration or a deliberate searchable projection. | +| AST cache | `graphify/cache.py:39`, `:429-494`, `:933-998`, `:1071-1118` | Cache keys use source content and the relative path; namespace uses installed distribution version plus cache schema. Relative paths and metadata round trips matter. | +| AST-only updates | `graphify/watch.py:1415-1446` rebuilds code without LLM calls | Preserve offline deterministic extraction and integrate Qt dependency invalidation with this path. | +| Incremental context | `graphify/watch.py:1716-1818`; `graphify/extract.py:8658-8662` | Unchanged graph nodes can be indexed as resolver context; a new Qt/QML resolver still needs the facts required to reconstruct relationships. | +| Persistence integrity | `graphify/export.py:272`; `graphify/watch.py:1221`; `graphify/build.py:1926-1953` | Retain partial/zero-node/shrink guards, portable sources, explicit ownership and directedness across updates. | +| Watch recognition | `graphify/watch.py:318`, `:2348-2387`, `:2437-2438` | Watched suffixes derive from code/document sets. Extensionless metadata is currently rejected by the event handler. | + +## Prioritized gaps + +Priorities indicate development order. These are functional and integration gaps, not claims of exploitable security defects. + +### A01 — P0: QML cannot enter the deterministic pipeline + +`.qml` is absent from `CODE_EXTENSIONS`, `_DISPATCH` and the language registry. `classify_file()` returns `None` for unknown suffixes (`graphify/detect.py:517-551`); directory `collect_files()` admits only dispatch suffixes (`graphify/extract.py:8876`, `:8904-8909`). There is no QML parser dependency in `pyproject.toml:14-47`. + +Adding a suffix alone is insufficient. Select and validate a parser, implement the extractor, register all entry points, provide actionable unavailable-parser diagnostics, and ensure that malformed QML cannot silently produce a cached empty or misleadingly complete graph. The first acceptance fixture must show QML entering both scan and extraction paths. + +### A02 — P0: Graph identity needs QML component and instance scopes + +`normalize_id()` casefolds and normalizes names (`graphify/ids.py:50-93`); `_file_stem()` strips the extension (`graphify/extractors/base.py:58-82`). Generic type-like classification is based largely on labels (`graphify/extractors/resolution.py:1118-1128`). Repeated `onClicked`, `width`, `id` values and nested object declarations need identities that cannot collapse into unrelated members. + +Define identity before feature extraction: repository-relative source, component scope, object/inline-component scope, member kind and original case-sensitive name, with deterministic collision salts compatible with the existing ID contract. Test `Button.qml` and `Button.cpp`, different directories containing `Main.qml`, inline components, repeated anonymous objects and names that normalize identically. Labels alone must never establish a bridge to an unrelated C++ or JavaScript symbol. + +### A03 — P1: Imports, component visibility and named metadata are missing + +No parser or resolver for `qmldir`, `.qmltypes`, QML URI/version/alias imports, local component directories or resource URLs was found in `graphify/` or tests. No dedicated CMake/qmake/Qt resource integration was found. Directory collection and watch handling are suffix-based, so an extensionless `qmldir` needs recognition beyond `_get_extractor()`. + +Introduce one consistent source-recognition rule used by detection, collection, update and watch. Build a corpus-scoped module/type index with source-backed candidates, explicit aliases and recorded ambiguity. Keep missing Qt SDK types as declared external references or unresolved facts; do not guess a local type by name. Treat `.qmltypes` metadata as input data with its own provenance and precedence, not as proof that an unavailable runtime object exists. + +### A04 — P1: Declarative relationships and script semantics are missing + +No QML extraction exists for properties, aliases, signals, methods, handlers, bindings, `Connections`, inline components, singletons or JavaScript embedded inside QML. Parsing the whole file as JavaScript would lose QML's declarative structure; parsing script spans without scope and byte-position mapping can misattribute calls and lines. + +Add source spans and component/object scope first. Distinguish reads, writes, binding dependencies, handler bodies and explicit signal connections. Keep runtime-selected targets unresolved when static evidence is insufficient. Reuse the JavaScript parser only for well-delimited script regions with the enclosing QML context and original source locations. Cover ordinary helper scripts and QML-specific script directives as separate cases. + +### A05 — P1: Qt C++ semantics and bidirectional QML bridges are missing + +`extract_cpp()` only performs C++/CLI normalization and test-macro augmentation (`graphify/extract.py:2895-2912`). No dedicated `Q_OBJECT`, `Q_GADGET`, `Q_PROPERTY`, `Q_SIGNAL`/`signals`, `Q_SLOT`/`slots`, `Q_INVOKABLE`, `Q_ENUM`, `QML_ELEMENT`, QML registration, context-property or `QObject::connect` pass was found. + +Generic C++ parse recovery can retain some surrounding declarations, but that does not provide a Qt property model, registration identity or signal/slot endpoint resolution. Build a bounded source overlay that consumes current C++ nodes and records Qt declarations and registration facts with exact provenance. Initially resolve only explicitly evidenced registrations, properties and callable members; macros, overloaded signatures, private visibility and dynamic context objects need negative tests. + +For `REQ-QML-016`, ordinary C++ call extraction may retain a signal-method call or a `connect()` invocation. Inspection found no Qt-specific pass that identifies an emission site, sender object, signal signature, receiving object, slot/lambda target and connection mode as one relationship. The existing PR's signal/slot declaration tags do not fill this gap. Add source-backed handling for `emit`/`Q_EMIT`, pointer-to-member and literal `SIGNAL`/`SLOT` connection forms, explicit overload selection and context-bound lambdas. Retain `Qt::AutoConnection` and explicitly selected modes without claiming runtime delivery order or thread affinity from a static call. Qt distinguishes signal emission, direct slot calls and connection-dependent delivery in its [signals and slots documentation](https://doc.qt.io/qt-6/signalsandslots.html). + +For `REQ-QML-017`, no dedicated pass was found for C++ obtaining QML-created objects through literal `load`/`loadFromModule`, `QQmlComponent::create`, `rootObjects()`/`rootObject()`, or literal `objectName`/`findChild` selection, then accessing their APIs through `property`/`setProperty`, `QQmlProperty`, `QMetaObject::invokeMethod`, or signal connections. Generic extraction can retain ordinary calls to these APIs; it does not identify the source QML component or member behind them. Trace an explicitly evidenced loaded component and object handle to declared QML properties, methods and signals, retaining the C++ access site and direction. Distinguish a QML `id` from an `objectName`; duplicate names, computed member names, unresolved object creation and dynamic descendants must not produce guessed targets. These APIs and their distinct property-write behavior are described in [Qt's C++ interaction guide](https://doc.qt.io/qt-6/qtqml-cppintegration-interactqmlfromcpp.html); version-specific loader APIs need an explicit minimum-version gate from the [engine reference](https://doc.qt.io/qt-6/qqmlapplicationengine.html). + +The design must also recognize source-backed exposure patterns in existing applications: a literal `setContextProperty("backend", object)` with an established object type and engine/context scope; `setContextObject(object)` where that context and the object's exposed members are source-resolved; and literal initial-property maps passed to a known QML component through `setInitialProperties` or `createWithInitialProperties`. These are bounded evidence-based cases, not permission to infer a global object from any matching name. Computed names, unresolved context ownership and unknown map contents remain explicit unresolved facts. Consult [context property integration](https://doc.qt.io/qt-6/qtqml-cppintegration-contextproperties.html) and the [component API](https://doc.qt.io/qt-6/qqmlcomponent.html) when defining those acceptance fixtures. + +Cross-language guards are an additional integration constraint. `graphify/build.py:1383-1402` drops inferred `calls` when language families differ, including an unknown `.qml` family versus C++. It also rejects certain `imports`/`references` when both families are known and differ. Any narrowly evidenced bridge allowance must preserve the existing anti-phantom-call regressions. Assigning all QML to the native language family would hide this distinction. + +### A06 — P1: Metadata-dependent incremental correctness needs design + +The current AST cache is per file; it does not provide a Qt module dependency graph (`graphify/cache.py:429-494`, `:933-957`). Resolver activation uses suffixes in the supplied input paths (`graphify/resolver_registry.py:78-80`). Partial rebuilds preserve unchanged graph records (`graphify/watch.py:1431-1435`) and add bounded unchanged context to resolution (`:1716-1818`). + +Changing only `qmldir`, `.qmltypes`, a C++ registration, a resource alias or an import root can change relationships in unchanged QML. Design dependency ownership, fact persistence, affected-source re-resolution and stale-edge eviction explicitly. Compare incremental output against a clean full rebuild for every metadata-only change, deletion, rename, ambiguity introduction and ignore-rule change. Development changes at the same package version must deliberately invalidate the relevant AST cache; distribution version namespacing alone does not invalidate each local code edit. + +### A07 — P1: Multiple relationships between the same endpoints can be lost + +`build_from_json()` creates `nx.Graph` or `nx.DiGraph` (`graphify/build.py:1031`), and later applies relation precedence before `add_edge()` (`:1413-1468`). One endpoint pair can retain only one edge in either simple-graph mode. `graphify/multigraph_compat.py:1-10` is a future-mode capability probe; it does not enable multigraph assembly. + +A component can read, bind, write and connect to the same target. Use explicit member/binding/handler nodes and retained structured facts within existing compatibility constraints, then test extraction-to-build-to-export preservation. Do not claim lossless Qt relationship coverage merely because extraction emitted every edge. An upstream multigraph project should remain a separate compatibility decision. + +### A08 — P2: Consumers do not automatically understand new relations + +The validator permits new relation strings, but affected traversal uses a fixed default relation list (`graphify/affected.py:12-32`), call-flow selection prefers a fixed set (`graphify/callflow_html.py:566-579`), and its call table filters another set (`:1250`). Direction is persisted using `_src`/`_tgt` in simple graphs (`graphify/build.py:1413-1429`). + +Prefer established relations plus precise Qt context where semantics match. If new relations are necessary, update query, affected traversal, call-flow, report/wiki and relevant exporters with consumer-level regression tests. Verify both direction and confidence; a new edge that serializes successfully can still be absent from default user-facing answers. + +### A09 — P2: Parser packaging, platform coverage and upstream gates are incomplete + +The package explicitly lists shipped Python packages (`pyproject.toml:147-149`) and pins tree-sitter API ranges (`:19-47`). A grammar spike must establish maintained source, license compatibility, ABI/API compatibility, offline operation and installability on supported Python/OS combinations. Qt SDK installation must not become a requirement for ordinary source extraction. + +The current CI runs the full suite with extras on Ubuntu at Python 3.10, 3.12, 3.13 and 3.14 (`.github/workflows/ci.yml:49-74`); it has no Windows/macOS test matrix in that workflow. New grammar wheels and path behavior require targeted Windows/macOS coverage. Push/PR branch filters list version branches and `main` (`:5-7`), so a push to a `codex/` development branch alone will not run this workflow unless it is dispatched or a matching-base PR exists. The security scans are currently non-blocking (`:100-106`); a green overall run is not proof those findings are absent. + +## Security and correctness boundaries for the extension + +`SECURITY.md` defines Graphify as a local development tool whose AST path parses source without executing it. Retain that behavior for Qt/QML: importing modules, instantiating components, loading plugins, running CMake/qmake, invoking `moc`, or starting a QML engine must not happen during ordinary analysis. + +QML, `qmldir`, generated `.qmltypes`, build files and resource manifests are untrusted inputs. Metadata references must not widen the scanned corpus through absolute paths, `..`, symlinks or ignored files. Parse resource XML without external entities or DTD resolution; the existing project XML safety helper (`graphify/extract.py:5721`) is a useful precedent. Apply explicit limits to bytes, nesting, item count and resolver fan-out; return bounded diagnostics on malformed input. + +Record evidence per fact and edge: declaring source, location, resolution basis and confidence. Distinguish an observed registration statement from proof that it executes for a particular runtime configuration. Imported Qt types, generated members, `NOTIFY` signals, asynchronous connections and dynamically loaded components have different evidence requirements. A name match is insufficient. + +Preserve portable IDs and paths in every new metadata bucket; `graphify/cache.py:573`, `:796`, `:813`, `:836` already has recursive portability handling, and `graphify/watch.py:388-411` has explicit source-key handling. Verify new structured facts across save/load, separate cache locations and relocated checkouts. Do not include machine-specific paths or credential data in public reports or fixtures. + +## Reproducible baseline experiments + +The setup verification already reproduced the B01/B02 classifications below; detailed commands and focused test results are in [VALIDATION.md](VALIDATION.md). The remaining experiments are proposed acceptance work whose expected outcomes are inferred from inspected source. Use temporary corpus and output directories; preserve the imported baseline and commit no generated graph/cache files. + +Observed baseline classifications: `Main.qml`, `Panel.ui.qml`, `plugins.qmltypes`, `qmldir`, `app.pro` and `assets.qrc` each return no classification and no extractor. `CMakeLists.txt` is a document with no AST extractor. Control files `backend.hpp` and `helpers.mjs` are code and select `extract_cpp` and `extract_js`, respectively. + +| Experiment | Minimal corpus or mutation | Expected baseline limitation / acceptance evidence | +| --- | --- | --- | +| B01: Entry points | `Main.qml` containing an import and a root object; an ordinary `helper.js`; a plain C++ class | Observed: QML classifier and extractor return `None`. Source inspection shows directory omission. Ordinary JS/C++ establish controls; add end-to-end explicit-file and directory tests. | +| B02: Named metadata | Extensionless `qmldir`, `plugins.qmltypes`, `resources.qrc`, `CMakeLists.txt` | Observed: Qt metadata has no extractor; `CMakeLists.txt` follows the document path. Add scan/collector/watch consistency tests before shared filename recognition. | +| B03: Qt parser recovery | QObject class with `Q_OBJECT`, `Q_PROPERTY`, signal, slot and invokable method; paired header/implementation | Capture node/edge labels and parse warnings. Compare to equivalent plain C++ without macros. Determine which declarations survive; do not infer Qt awareness from survival. | +| B04: Bridge precision | Explicit registered C++ type used by QML; unrelated class with identical member names; duplicate type registrations | Before implementation there is no QML bridge. Acceptance requires only the evidenced type/member targets, with unresolved or ambiguous cases visible and no name-only edges. | +| B05: Identity | Same QML basenames in two module directories, case-distinct names, repeated handlers and inline components | Stable distinct IDs, original labels and correct scopes after sequential, parallel, cached and relocated runs. | +| B06: Build/export loss | Extracted `references` and `uses` between the same two source-backed nodes | Baseline simple graph collapses the pair to one surviving edge; quantify and verify the extension's chosen representation across JSON reload. | +| B07: Partial rebuild | Change only `qmldir` version/type mapping, `.qmltypes`, C++ registration or resource alias; then delete a referenced component | Incremental output equals normalized clean-build output in supported cases; stale edges disappear and unchanged sources remain intact. | +| B08: Corpus isolation | Refer to excluded/out-of-root files and a symlink escaping the root; fake XML external entity | No source expansion or host-file reads; bounded diagnostics; no Graphify cache created inside a separate analyzed source tree. | +| B09: User queries | Ask which type a component uses, which C++ member backs a binding, and which handlers are affected by a signal/property change | Correct provenance, direction, confidence and evidence survive default MCP/query/affected/export consumption. | +| B10: Packaging | Install a built wheel in a clean environment and parse the checked-in QML fixtures without network/Qt SDK | Required extractor code/grammar is packaged, optional-parser behavior is explicit, and supported OS/Python lanes work. | +| B11: Qt event relationships | Signals/slots using keyword and `Q_*` forms; `emit`/`Q_EMIT`; pointer-member, literal macro, overload and lambda connections; explicit and automatic modes | Ordinary calls are distinguished from source-backed emissions/connections, all endpoints and modes retain provenance, and unknown receivers/overloads stay unresolved. Verify queries, exports and incremental change/removal of connections (`REQ-QML-016`). | +| B12: Bidirectional object APIs | A literal-loaded QML component obtained through a root/create handle; a unique named child; C++ property read/write, method invocation and signal connection; source-backed context-object/property and initial-property maps | C++ sites resolve to the declared QML object/member and direction; QML sites resolve to their evidenced exposed C++ objects. Duplicate/computed object or member names do not create guessed edges. Compare incremental output against clean rebuilds when either side changes (`REQ-QML-017`). | + +For B01, a minimal read-only API probe after the environment is installed is: + +```python +from pathlib import Path +from graphify.detect import classify_file +from graphify.extract import _get_extractor, collect_files + +root = Path("") +qml = root / "Main.qml" +print(classify_file(qml)) +print(_get_extractor(qml)) +print([p.relative_to(root).as_posix() for p in collect_files(root)]) +``` + +For B03, run `extract([header, implementation], root=corpus, cache_root=temporary_output, parallel=False)` and inspect source-backed nodes, Qt-specific facts, relationships and `parse_errors`. Repeat from a clean cache after grammar/normalization changes. Parse warnings indicate uncertainty and must not be discarded solely to make this experiment appear green. + +## Verification commands and upstream compatibility + +Use the pinned lockfile and the repository's own workflow conventions once the environment is available: + +```text +uv sync --all-extras --frozen +uv run --frozen python -X utf8 -m pytest tests/ -q --tb=short +uv run --frozen ruff check . +uv run --frozen pyright +uv run --frozen python -m tools.skillgen --check +``` + +Relevant existing regression groups are detection/ignore and non-regular-file tests; C++ declarations, header pairing and cross-file calls; language resolver and cross-language guard tests; ID/cache portability tests; incremental/stale-prune/partial-extraction tests; query/affected direction tests; export/round-trip tests; and wheel packaging tests. Reuse these contracts and add fixtures that demonstrate new failure modes, rather than replacing the generic C++/JS behavior. + +The module-based pytest invocation supplies an importable main module to Windows multiprocessing, and `-X utf8` avoids default-codepage differences. The setup run using this form recorded 1,265 passed, 53 skipped and two Windows deleted-current-directory failures across six focused suites; it was not a full-suite run. Ruff and generated-skill checks passed. Pyright reported 634 errors and four warnings, including optional imports and existing typing issues; the baseline must not be described as passing all checks. See [VALIDATION.md](VALIDATION.md) for exact commands and limitations. + +If documentation for installed agent skills changes, edit authoritative fragments under `tools/skillgen/`, regenerate according to `CONTRIBUTING.md`, and run the workflow's schema/coverage/round-trip checks. After implementation code changes, run `graphify update .` as required by the root development guidance and record any inability to refresh the graph. + +The architecture and increment plan should turn A01-A09 into narrow upstream contributions with explicit supported syntax, unsupported runtime cases, regression evidence and full-build/incremental equivalence gates. Complete QML support is a measurable sequence of language-analysis capabilities; this audit establishes the baseline from which to measure it. diff --git a/docs/qt-qml/CODE_REFERENCE.md b/docs/qt-qml/CODE_REFERENCE.md new file mode 100644 index 0000000000..7dcb28cd2e --- /dev/null +++ b/docs/qt-qml/CODE_REFERENCE.md @@ -0,0 +1,668 @@ +# Qt/QML extension code reference + +The current HTML exporter owns source-edge count projection required by +REQ-QML-020 membership links, alongside Qt metadata and tooltips. Its inspector +keeps upstream degree display. General recovery/navigation owners linked below +are deferred historical source, not current imports. + +The [upstream contribution scope](UPSTREAM_CONTRIBUTION.md) retains Qt/QML +analysis, shared-source prerequisites and applicable compatibility owners. +REQ-QML-019/021 and the generic viewer portions of INC-QML-09/13/16 are deferred, +as is native Codex transport under REQ-CORE-001-AC01/AC05 and DEC-CORE-01. +The deferred sections below retain their original IDs and frozen references. + +The complete fork is frozen at +[`82a4f29`](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4). +Its [integrated hosted checkpoint](../../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2) +and earlier measurements/results apply to their recorded revisions. They do not +verify the narrowed contribution head. Fresh applicable acceptance evidence +belongs to [traceability](../../tests/TRACEABILITY.md). + +The first table records baseline owners, with their retained extension seams. +[AUDIT.md](AUDIT.md) retains historical line evidence. The second table lists +implemented QML/Qt owners through INC-QML-06 and local INC-QML-07 consumer hooks. +Revision-specific verification remains in [VALIDATION.md](VALIDATION.md). + +| Existing owner | Role and extension considerations | +| --- | --- | +| `graphify/detect.py` | `classify_file`, `detect`, `detect_incremental`, code extensions and corpus boundaries; exact Qt metadata names/suffixes use shared admission | +| `graphify/extract.py` | Dispatch, language-family handling, `extract_cpp`, aggregate `extract`, ID/path normalization and resolution passes | +| `graphify/extractors/` | Focused language extractors; preferred home for source-local QML and Qt metadata responsibilities | +| `graphify/extractors/engine.py` | Shared AST traversal and generic C++ configuration; preserve normal C++ signatures, calls and macro normalization while a separate Qt overlay refers to these declaration IDs | +| `graphify/resolver_registry.py` | Post-extraction cross-file resolver activation; Qt context and exact metadata filenames must activate resolution after QML-only, C++-only or metadata-only changes | +| `graphify/cache.py` | Persistent generic per-file cache; Qt source/metadata and native syntax in an explicit Qt context bypass reads/writes under `qt_incremental` policy | +| `graphify/watch.py` | Manual update/watch rebuild, watched-file admission, incremental extraction and persistence guards | +| `graphify/install.py` | Upstream installer owns hook command serialization and JSON merging/publication; the [frozen native Codex serializer](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/codex_hook_command.py) and [shared compatibility decision](../COMPATIBILITY.md#dec-core-01--windows-hook-literal-transport) describe the deferred independent literal-transport feature | +| `graphify/build.py` | Graph merge/provenance and simple graph construction; independent source sites/endpoint-role facts retain distinct Qt mechanisms | +| `graphify/paths.py` | `load_node_link_graph` restores contract-versioned QML edge orientation from serialized endpoints on undirected reload | +| `graphify/__main__.py`, `graphify/cli.py` | CLI dispatch/facade and query/explain/path/affected implementation entry points | +| `graphify/serve.py` | Optional MCP query and graph consumer contracts | +| `graphify/export.py`, `graphify/exporters/` | Serialization and presentation consumers | +| `tools/skillgen/fragments/` | Authoritative assistant instruction source; generated outputs require skillgen checks | +| `pyproject.toml`, `uv.lock` | Python support, parser dependencies/extras, explicit packaged modules and frozen environment | +| `.github/workflows/ci.yml` | Full Ubuntu source matrix with reviewed-wheel/disposable-service proof, focused native Windows compatibility, retained revision/integrity evidence and PR-only obsolete-run cancellation; [shared compatibility procedure](../COMPATIBILITY.md#hosted-runner-proof-procedure) owns setup, cleanup and execution gaps | +| `tests/test_detect.py`, `tests/test_languages.py`, `tests/test_extract.py` | Detection, language and aggregate extraction regression coverage | +| `tests/test_build.py`, `tests/test_cache.py`, `tests/test_watch.py` | Graph integrity, cache and incremental persistence regression coverage | + +| Implemented owner | API and responsibility | +| --- | --- | +| `graphify/extractors/qml.py`, `qml_ast.py` | `extract_qml`, lazy optional parser and source/AST limits; failed parses return diagnostics and no authoritative declarations | +| `graphify/extractors/qml_facts.py` | `FactBuilder`, `make_qml_id`, fixed-width `make_scope_key`, span helpers and bounded `encode_metadata`/`qml_metadata` literal transport | +| `graphify/extractors/qml_declarations.py` | `Declarations`: components, inline scopes, objects/groups/members/imports and conservative template-body barriers | +| `graphify/extractors/qml_metadata.py` | `extract_qmldir`/`parse_qmldir`: standard-library literal records, not `.qmltypes` or plugin execution | +| `graphify/qml_resolution_types.py` | `Resolution`, exact-candidate selection, accepted relative references and portable site/edge values | +| `graphify/qml_module_index.py` | `QmlModuleIndex`: ordered explicit roots, provider/version/internal/singleton lookup and separate type/script namespace roles | +| `graphify/qml_scope.py` | `QmlProjectIndex.resolve_type`, `resolve_member`, `follow_member`; source/component/object/inheritance lookup only | +| `graphify/qml_resolution.py` | `build_qml_index`, `resolve_qml_project`: run-owned indexes and append-only fresh import/type-resolution sites | +| `graphify/extractors/qml_expressions.py`, `qml_js_scopes.py` | `collect_relationships`, per-occurrence facts and JS binding/shadow/reassignment rules; no generic `raw_calls` | +| `graphify/extractors/qml_script_syntax.py`, `qml_scripts.py` | `parse_script`, `collect_qml_scripts`: bounded byte-preserving directives and QML-owned overlays of admitted JS resources | +| `graphify/qml_relationship_lookup.py` | `RelationshipLookup`: scoped alias chains/cycles and imported script visibility | +| `graphify/qml_relationships.py` | `resolve_qml_relationships`: fresh expression status and read/call/alias/subscription/notify-signal projection; borrowed context is read-only | +| `graphify/qml_projection.py` | `allows_qml_script_edge`: exact typed script-file import proof and QML script-function calls through the existing builder guard | +| `graphify/qml_safety.py` | `qml_refresh_required`, `require_qml_watch_root`, `require_complete_qml`: conservative refresh and fail-before-publication checks | + +`extract.py` forwards explicit roots, overlays admitted scripts before aggregation, +then joins QML project and relationship facts after generic resolution. It records +join failures for publication rejection. Generic JS/C++ owners are preserved; +Qt/QML extractor children do not import the facade. The imported Markdown +extractor's ambient-root exception is recorded in DESIGN.md. [DESIGN.md](DESIGN.md) defines the current +interfaces and [ERRORS.md](ERRORS.md) owns their diagnostic contracts. + +The Qt C++ overlay owns source-local meta-object declarations, emissions, +connect/disconnect syntax, loader/access sites and literal context/initial-property +facts. Its resolver owns scoped module/resource joins and +engine/component/view-to-QML-object provenance. It must support native C++ events +without requiring QML parsing. REQ-QML-008, REQ-QML-016 and REQ-QML-017 in +[REQUIREMENTS.md](../REQUIREMENTS.md) define the separate exposure, event and reverse +object-access acceptance contracts; none is implemented by the imported generic +C++ extractor alone. [DESIGN.md](DESIGN.md) records implemented relation contexts and +source ownership, including private-slot meta-object endpoints and compatible +ordinary member-pointer receivers. + + +## Native Qt source owners + +The corrected semantic seams are `QtQmlAccessIndex.member`/`find_child` +(INC-QML-17), declaration-owned context/provider joins (INC-QML-18), shared native +type/alias authority (INC-QML-19), and bounded loader admission (INC-QML-20). +[FOLLOWUP_AUDIT.md](FOLLOWUP_AUDIT.md) preserves baseline findings and current +dispositions. Same-file canonical IDs, static reflection API shadows and failed +Windows replacement retention are corrected by INC-QML-21/22/23; the following +source/publication seams describe the wider INC-QML-24–27 correction scope. + +| Boundary | Implemented owner | +| --- | --- | +| Annotation normalization/original source view | graphify/extractors/qt_cpp_syntax.py | +| Canonical class/member mapping | graphify/extractors/qt_cpp_mapping.py | +| Exposure/property/registration collection | graphify/extractors/qt_cpp_exposure.py, qt_cpp_properties.py, qt_cpp_registration.py | +| Portable Qt transport | graphify/extractors/qt_cpp_facts.py | +| Source event/access collection | graphify/extractors/qt_cpp_events.py, qt_cpp_access.py and their call/variable/pattern helpers | +| Scoped native QML provider/member lookup | graphify/qt_qml_bridge.py, qt_qml_bridge_members.py | +| Native event projection | graphify/qt_event_index.py, qt_event_resolution.py | +| Component/object/provider access | graphify/qt_qml_access_index.py, qt_qml_access_resolution.py, qt_context_bindings.py, qt_qml_event_access.py | +| Post-canonical scratch orchestration | graphify/qt_qml_pipeline.py | +| Cross-family endpoint proof guard | graphify/qt_qml_projection.py | + +Metadata readers are publicly discovered/dispatched with INC-QML-05. Their index +consumes accepted facts without executing a build or expanding the corpus. + +| Metadata boundary | Implemented owner | +| --- | --- | +| Bounded original-byte source facts | `graphify/extractors/qml_project_read.py` | +| Literal Qt module/source declarations | `graphify/extractors/qml_cmake.py`, `qml_cmake_syntax.py`, `qml_qmake.py` | +| Generated tooling descriptions | `graphify/extractors/qml_types.py` | +| Entity-safe resource declarations | `graphify/extractors/qml_resources.py` | +| Exact project/member/resource lookup | `graphify/qt_project_index.py`, `qt_resource_index.py` | +| Source/generated conflict evidence | `graphify/qt_generated_conflicts.py` | + + +INC-QML-05 public seams: detect.classify_file exact CMakeLists.txt and Qt suffixes; +extract._get_extractor/_safe_extract forward the explicit scan root; collect_files +uses identical named-file/ignore/root admission. LANGUAGE_EXTRACTORS exposes the +four bounded metadata readers. qt_qml_pipeline owns project/native index ordering +and warning attachment. Partial readers bypass syntax cache and reject publication. + + +INC-QML-06 public seams: extract(...qml_import_roots=None, refresh_native=False) carries +ordered lookup roots and native cache policy to a per-run Qt pipeline. Worker +three/four-tuples remain compatible; a fifth bool carries context policy. +qt_analysis_state.inspect_qt_analysis and commit_qt_analysis separate inspection +from successful publication. qt_incremental.plan_qt_refresh never expands corpus. + +INC-QML-07 consumers use `qt_qml_search.search_attributes` for bounded public semantic +fields; raw transport and opaque scope keys stay outside search text. +`qt_affected.owned_ancestors` promotes current source-owned dependency sites to +their enclosing members/components. `qt_relationship_views` and `qt_html` retain +distinct event/access evidence in HTML; `qt_coverage` reports unresolved source +sites separately from edge confidence. `qt_export` supplies lossless nested JSON +properties and logical endpoints for Qt graph-database payloads. Existing CLI, +MCP, report, HTML and export owners call these focused helpers. +See [EXPORT_MATRIX.md](EXPORT_MATRIX.md) for exact reload/omission contracts. + +The generated assistant AST stage calls `inspect_qt_analysis` read-only, passes +its ordered `import_roots` and `has_qt` to `extract`, then gates AST publication. +It does not commit `.qt_analysis.json`; CLI/watch retain checkpoint ownership +after successful graph/manifest publication. The generator's exact sanctioned +line policy retains frozen baseline checks; its current 1,450-line legacy +exception and extraction exit are in [PLATFORM_MATRIX.md](PLATFORM_MATRIX.md). + +## Adoption native parser compatibility owner + +`graphify/extractors/qt_cpp_compat.py::recover_cpp_syntax(source, code, *, parse, walk)` +is a pure equal-length parse-view helper. `needs_recovery(code)` is its cheap +candidate admission. `qt_cpp_syntax` owns source reads, lexical masking, parser +errors and limits and calls this helper before its final syntax gate. Neither +module imports the extractor facade. Empty default clauses and Qt-unused argument +syntax recovery do not own source facts or runtime interpretation. + +`qt_cpp_syntax.lexical_code` also owns preprocessing-number token recognition +before character-quote masking, preserving numeric separators and later source +visibility. The unchanged numeric token remains subject to parser validation. + +`graphify/cache.py` owns the AST schema epoch; `graphify/qt_incremental.py` owns +native refresh relevance and Qt policy fingerprint. Graph/manifest/analysis-state +publication remains in the existing CLI/watch owners. The migration keeps those +owners and interfaces intact and introduces no alternate cache or graph writer. + +## HTML community-view interfaces + +Disposition: **Deferred for the upstream contribution** under REQ-QML-019/021 +and the generic viewer portions of INC-QML-09/13/16. This section records the +frozen fork's owners and evidence. Upstream generic HTML behavior is restored; +retained Qt HTML metadata, tooltips, source direction and omission contracts +continue through the INC-QML-07 helpers above. + +The [frozen community-preparation module](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/exporters/html_communities.py) +defines `prepare_html_communities`, which owns complete +large-view partition validation and local grouping/name recovery. The existing +HTML exporter owns aggregate projection, deferred selection and atomic output. +The CLI resolves an explicit graph's analysis beside that graph and reports +skipped/failed view publication as unsuccessful. These are presentation owners; +source extraction, graph identities and analysis persistence remain unchanged. + +INC-QML-16 removes the Overview control and ranking state while retaining checked +full startup, community filters, search, Select All/None and aggregate rendering. +Its historical status is **Locally verified at the recorded emitted-script and +reviewed installed-artifact boundaries** under changed REQ-QML-019-AC04 and new +REQ-QML-021-AC01–AC03. +Native browser/device and other-platform behavior remains unverified. + +| Owner | Camera/control responsibility | +| --- | --- | +| [Frozen camera helper](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/exporters/html_navigation.py), `MIDDLE_PAN_SCRIPT` | Isolated camera-only IIFE; incremental client delta/current-scale movement, input/capture guards and cleanup; no graph/dataset writes or wheel hook | +| [Frozen HTML exporter](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/exporters/html.py), `to_html` | Remove Overview markup/function/state/ranking; inject the helper once after existing network/initial-physics setup; retain emitted payload and selection/search/inspector interfaces | +| [Frozen middle-button tests](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) | Production emitted-script geometry, termination, invalid-camera/capture and coexistence/immutability evidence; recorded local source checkpoint passes | +| [Frozen initial-view tests](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) | Checked startup and filters/all/none/search/partial-membership behavior; obsolete Overview expectations were replaced in that revision | + +The camera helper and new tests remain below 300 lines. The cohesive legacy HTML +owner retained its 810-line ceiling at that checkpoint; revision-specific +integration measurements belong to DESIGN/validation. +[D16](ARCHITECTURE.md#d16--middle-button-input-owns-only-the-temporary-camera) +defines temporary camera ownership. The existing vis camera API is reused with no +new SDK, persistence operation, graph diagnostic or Qt policy/schema change. +Actual navigation/selection outcomes pass 60 focused cases and reviewed installed- +artifact script checks pass. Source/wheel/isolated installation contains 156 byte- +equal Python payloads. Native browser visual/device, new hosted and other-platform +checks remain explicit gaps; this evidence does not establish a full device profile. + +## Native source-ownership correction seams (INC-QML-10) + +This table records the historical INC-QML-10 correction and its verified artifact. +Current policy/schema and constructor seams are recorded in INC-QML-12/13 below. +These owners retain their interfaces and operate after generic canonical +identity/provenance is available: + +| Owner | Correction responsibility | +| --- | --- | +| `graphify/extractors/qt_cpp_mapping.py::CppMapping` | Complete-class eligibility and exact out-of-line callable mapping from accepted definition file/location and class containment | +| `graphify/extractors/qt_cpp_exposure.py::_class_facts`, `enrich_qt_cpp` | Additive `is_definition` from AST body presence; retain forward facts/IDs and exact producer source file/span when carrying fresh or accepted-context complete-body authority into per-run binding | +| `graphify/qt_event_index.py::QtEventIndex` | Establish class-qualified event endpoints from unique complete definitions; preserve real ambiguity/unavailable evidence | +| `graphify/qt_qml_bridge.py::QtQmlBridgeIndex` | Require accepted complete class evidence for native provider/meta-object availability; a retained forward fact does not establish a provider | +| `graphify/qt_incremental.py::QT_POLICY_VERSION` | Epoch 3 analysis invalidation at unchanged package/source versions; AST schema remains 6 under the cache owner | + +`definition_file`/`definition_location` remain accepted canonical attributes and +do not replace header declaration provenance. Qt member/emission/access facts +keep their original implementation spans and refer to proven canonical owners. +Class source file/span belong to the original producer. `enrich_qt_cpp` preserves +both when borrowing complete-class facts so `CppMapping.bind_classes` recognizes +the same body consistently with fresh AST records. Context node/metadata +dictionaries remain immutable inputs; distinct body locations do not coalesce. +Direct and actual pipeline/build/reload context tests pass. This internal record +transport fix left persisted fact shape, policy 3 and AST schema 6 unchanged at +that revision; INC-QML-12/13 subsequently use policy 5/schema 7. +The source/root, metadata validation, checkpoint and graph-writer boundaries are +unchanged. [D13](ARCHITECTURE.md#d13--complete-definitions-and-exact-provenance-authorize-native-ownership) +and [DESIGN.md](DESIGN.md#native-ownership-authority-inc-qml-10) define authority +and unresolved-case limits; [VALIDATION.md](VALIDATION.md#inc-qml-10-native-source-ownership) +records that source/context, final broad and reviewed installed-artifact proof. + +INC-QML-11 uses `QtEventIndex._lookup_members` for bounded ancestor candidates, +`_bases` for canonical complete-class proof, and `_base_access` for source-owned +inheritance visibility. `member` stops name lookup before role/signature filtering; +`inherits(..., public_only=True)` gates external member-pointer conversion paths. +The producer `qt_cpp_exposure._class_facts` owns each canonical base/access pair. +Both files remain below 300 lines at this increment (186 and 184). No mutable +project state or new persistence owner is introduced. Policy 13 retires derived +facts; installed integration and explicit-emission admission remain separate exits. + +## Constructor and source-containment seams (INC-QML-12/13) + +| Owner | Implemented responsibility | +| --- | --- | +| `extractors/cpp_constructors.py` | Bounded AST class/constructor facts, exact prototype/type/scope join, ambiguity and consumer authority guard | +| `extractors/engine.py` | C++-only producer hook; no second extractor or analyzed project execution | +| `extractors/resolution.py::_merge_decl_def_classes` | Invoke constructor binding before existing ID-collision canonicalization; reject unsafe constructor merges | +| `extractors/qt_cpp_mapping.py::CppMapping.bind_classes` | Preserve exact source callable while preventing rejected/legacy constructor substitution by a header prototype | +| `extractors/qt_cpp_variables.py`, `qt_cpp_events.py` | Unproved constructor class authority cannot establish native `this` or an implicit emission receiver type | +| `extractors/qt_cpp_exposure.py::_class_facts` | Member/local-class source containment by unique accepted callable without native class/QObject/exposure authority | +| `exporters/html.py::to_html` and emitted inspector | Actual canonical internal/external source-edge counts and distinct connected-community counts; no graph mutation | +| `qt_source_containment.py`, `qt_qml_pipeline.py` | Exact accepted file-to-unknown-occurrence containment, explicit fresh AST authority and unchanged unresolved semantic roles | +| `cache.py`, `qt_incremental.py` | Historical INC-QML-12/13 artifact: AST schema 7 and Qt policy 5 same-package refresh; INC-QML-14 subsequently uses policy 6 with the same persistence owners | + +Constructor metadata version 1 carries bounded original names/lexical scope, +source spans, exact type-signature hashes and rejected/accepted class-binding +outcome. Canonical declaration and definition IDs remain authoritative; absent +or conflicting proof cannot gain an endpoint through Qt mapping. Source-only +containment does not change class IDs/native roles. D14 and DESIGN.md describe +these boundaries. Legacy module measurements/exceptions are versioned in +DESIGN.md; tests and exact commands belong to validation/traceability. + +## Project-membership seams (INC-QML-14) + +The initial INC-QML-14 profile is locally verified. INC-QML-39 extends accepted +input identity; focused and broader local source regressions pass. A fresh +artifact initially reproduced the separate INC-QML-40 facade gap; its correction +now passes fresh installed alias smoke. The applicable corrected source/native +profiles pass at the [verified hosted checkpoint](../../tests/TRACEABILITY.md#verified-hosted-runner-checkpoint--10cb15a), +with explicit capability exclusions and optional-wheel evidence limits. +The INC-QML-49/50 integration uses Qt policy 22 and AST schema 13 under D26/D27. +The owners below implement +REQ-QML-020-AC01–AC03 without a new parser or persistence pipeline. + +| Owner | Correction responsibility | +| --- | --- | +| `graphify/qt_project_membership.py::resolve_project_memberships` | Independent source-owned membership sites; literal/unique accepted canonical targets; canonical contained input identity admits real path aliases without rewriting lexical provenance, reading target content, expanding the corpus or mutating borrowed facts | +| `graphify/qt_project_index.py::QtProjectIndex` | Existing accepted module/source/resource evidence and lookup behavior; projection does not change the index's runtime-uncertainty boundary | +| `graphify/qt_qml_pipeline.py::resolve_qt_qml` | Narrow post-canonicalization helper hook, scratch publication and existing resolver failure guard | +| `graphify/qt_incremental.py::QT_POLICY_VERSION` | Historical INC-QML-14 policy-6/schema-7 refresh; current policy 22/schema 13 retain the same owners, preserve combined upstream/Qt facts and refresh prior followed Qt metadata provenance at unchanged package/source versions | +| `tests/test_qt_project_membership.py` | Public literal, ambiguity, source/span, direction and immutable-input production regressions; exact executed coverage belongs to traceability | +| `tests/test_qt_project_membership_updates.py` | Real CLI/watch changes, policy upgrade, prior-product retention/repair/repeat, aggregate inspection and replacement publication without borrowed mutation | +| `tests/test_qt_project_membership_aliases.py` | Real Windows short-path and parent-symlink admission; unchanged installed-smoke assertions, canonical/cold/warm/full/manual/watch equality, foreign/conflicting transport rejection and durable retention/recovery | + +Declaration-to-site `contains` uses `qt_membership_site`; a resolved site's +`references` uses `qt_project_source` or `qt_resource_membership` with confidence +`EXTRACTED`. Unresolved status/reason retains the source attempt without a guessed +endpoint. The helper, original source test, pipeline, lifecycle test and alias +test measure 198/283/106/267/222 physical lines, each below 300. Initial INC-QML-14 +CLI/watch, retention, consumer and reviewed-artifact evidence is recorded +separately from INC-QML-39 proof. The API accepts optional `fresh_ast_ids=()` and returns derived nodes/ +edges; starting node/edge-object identities authorize replacement publication +without depending on append offsets. Exact criterion evidence and local limits +remain in validation/traceability. + +## Receiver authority seams (INC-QML-17) + +`qml_declarations.Declarations` records construction owner/scope/kind and possible +parenting-change spans independently of lexical and visual-parent fields. +`QtQmlAccessIndex` validates source declaration containment, source spans, supported +QObject construction types and component boundaries, then searches only accepted +descendants at the requested depth. Its member interface delegates to receiver +member traversal, including accepted native provider views, without lexical-root +fallback. Readonly writes and unsupported type filters have explicit no-target +outcomes. The collector retains setParent attempts and the resolver invalidates +only an established receiver's component tree. + +`QtQmlBridgeIndex` assigns module bounds after accepted native provider admission; +rejected gadgets cannot create an empty namespace that crashes a join. Existing +scratch publication, native endpoint roles and graph direction are retained. +Policy 7 refreshes unchanged accepted inputs; AST schema 7 remains unchanged. +Exact source, lifecycle and reviewed artifact evidence belongs to traceability. + +## Declaration-owned integration seams + +- `extractors/qt_cpp_identity.py::CppDeclarationIdentity` owns per-unit source + declaration and bounded lexical lifetime evidence. +- `extractors/qt_cpp_identity.py::source_reference_key` validates transport before + indexing loads, root handles or providers; it has no name fallback. +- `qt_context_bindings.py::resolve_context_bindings` pairs exact engine/component + declarations and typed provider declarations within supported source lifetime. +- `qt_qml_event_access.py::_qml_endpoint` selects sender/receiver declaration + identity before cross-language handle lookup. + +The collector, resolver and test modules stay within 300 physical lines. These +helpers do not own persistence, execute analyzed code or establish runtime order. + +## Lexical native type seams + +- `extractors/qt_cpp_type_scope.py::NativeTypeScope` resolves literal native type + aliases at exact source positions; `resolve_class` requires accepted identity. +- `extractors/qt_cpp_type_aliases.py::add_type_dependency_facts` produces original + alias/include facts; `IncludedAliasShadows` validates accepted header shadows. +- `extractors/qt_cpp_variables.py::variables_at` applies the shared type index to + parameter/local types. Event and registration collectors consume that evidence. +- `extractors/qt_cpp_exposure.py::_class_facts` supplies canonical base names; + `qt_qml_access_index.py::_native_ancestry` requires accepted non-widget ancestry + for child construction. Reflection filter admission uses the same type scope. + +Facts remain source-owned, indexes run/unit-owned and persistence caller-owned. +Imported-header targets remain unavailable rather than falling back globally. + +## Literal loader seams + +- `extractors/qt_cpp_loaders.py::collect_loader_constructors` produces original + constructor load facts and component declaration-owned engine associations. +- `literal_url_expression`, `literal_loader_arguments` and + `literal_creation_arguments` admit bounded SDK URL/overload forms. +- `NativeTypeScope.callable_shadow` exposes source callable authority without + lending QUrl constructor semantics to a same-named function. +- `extractors/qt_cpp_access.py::collect_qt_cpp_access` integrates loadUrl and + constructor facts with existing declaration/handle ownership. +- `QtQmlAccessIndex.load` and `resolve_cpp_qml_access` reject unsupported loader/ + root forms before selecting a source component. Context providers retain the + exact associated engine; no new persistence owner or graph mechanism is added. + +## Qualified source, SDK authority and coordinated update seams + +- `extractors/cpp_identity.py::CppIdentity` owns exact named native scopes and + qualified class/member IDs. `cpp_class_proof` validates accepted class bodies. +- `extractors/cpp_member_identity.py` owns ordinary qualified member facts and + bounded using-namespace signature joins; `resolution._merge_decl_def_classes` + remaps canonical nodes, edges and raw calls together. +- `extractors/qt_cpp_type_scope.py::NativeTypeScope.sdk_type` rejects source + declarations as SDK authority. `qt_cpp_identity.CppDeclarationIdentity.type_binding` + retains original declared type/position independently of runtime identity. +- `extractors/qt_cpp_loaders.py::declared_sdk_type` applies that authority to + engine/component/view and URL wrapper admission; the access collector retains + uncertain observed source occurrences without lending target identity. +- `publication.py::ProductPublication` owns staging/snapshots/replacement/recovery; + existing serializers and `watch._rebuild_code` retain semantic/run ownership. + `qt_analysis_state.commit_qt_analysis` prepares candidate state in that stage. + `tests/test_publication.py::test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence` + exercises real partial setup and `Path.unlink` cleanup rejection across supported + Python versions; INC-QML-48 corrects only its failure-injection seam. +- `qt_orphan_cleanup.py::prune_stale_ast_orphans` receives explicit complete-refresh + authority, fresh IDs and graph references after caller-owned reconciliation. +- `qt_qml_bridge_members.py::QtMemberViews.property_notify` returns an established + native signal for a property's notifier. `qml_relationships` consumes it; + `extractors/qml_expressions` owns explicit versus implicit handler parameters. + +At the INC-QML-21–27 checkpoint, AST schema 9 and Qt policy 12 required fresh source/derived reconstruction. Current adoption epochs are recorded in validation. +Diagnostic and recovery contracts belong to [ERRORS.md](ERRORS.md); bounded +acceptance, platform/artifact results and outstanding gaps belong to traceability. + + +## Exact constructor source ownership (INC-QML-15) + +| Owner | Contract | +| --- | --- | +| `extractors/cpp_constructor_signature.py` | Bounded accepted parameter identity and distinct unsupported occurrence IDs before deduplication | +| `extractors/cpp_constructor_type_authority.py` | Direct source-file include/shadow proof, original spans and immutable admitted-corpus SDK authority | +| `extractors/cpp_constructor_binding.py` | Exact unique declaration/body/class joins and Qt source-span matching | +| `extractors/cpp_constructors.py` | Source constructor facts and retained public compatibility seams; ordinary `_signature` remains used by ordinary-member joins | +| `extractors/cpp_identity.py`, narrow `extractors/engine.py` hook | Canonical inline/out-of-line constructor IDs; unrelated producer IDs unchanged | +| `extractors/qt_cpp_mapping.py` | Select the actual constructor signature/span; preserve source callable when native class authority rejects | +| `cache.py`, `qt_incremental.py` | AST schema 10 / Qt policy 14; existing persistence/invalidation owners | + +At the constructor checkpoint the generic engine measured 7,720 lines; the final reference-return hook measures 7,728 within its 7,730 ceiling. The four +new hook lines select an existing producer owner; they add no domain logic to the +controller. Existing specialized migration sequencing remains its extraction exit. +New handwritten helpers and tests remain below 300 physical lines. Exact final +measurements, exclusions and evidence belong to design and validation. + +## Explicit event observation (INC-QML-28) + +`extractors/qt_cpp_calls.py::calls` owns the optional accepted AST annotation +profile; its default inventory remains compatible. `qt_cpp_events` owns event +mechanism dispatch and source facts, while `qt_event_index` owns endpoint lookup. +Computed receiver identity stays unavailable. Qt policy 15 invalidates old derived +sites; source AST schema 10 is unchanged. Both scanner/event modules and their new +tests remain below 300 lines. No new persistence owner or diagnostic code exists. + +## Project and header admission (INC-QML-08a) + +`extractors/qml_qmake_values.py` owns original lexical statement/path coverage; +`qml_qmake.py` produces project facts with separate build/tooling hints. +`cpp_header.py` owns only the masked classification prefix; `extract.py::_is_cpp_header` +delegates classification and retains dispatch ownership. This shrinks the legacy +facade to 8,993 physical lines within its existing 9,010 ceiling. New source and +tests remain below 300 lines. AST schema 11 / Qt policy 16 invalidate old facts. + +## Typed provider seams (INC-QML-08b) + +- `extractors/qt_cpp_api_shape.py`, `qt_cpp_api_types.py` and + `qt_cpp_provider_expression.py` produce original declaration/API expression facts. +- `extractors/qt_cpp_identity.py::declared_type_binding` supplies original root + shape under the existing lexical lifetime/reassignment owner. +- `qt_declared_provider.py::DeclaredProviderIndex` owns one run's canonical + class/member/shape/accessor validation; it opens no corpus files. +- `qt_context_paths.py` owns finite child API traversal and consumer revalidation. +- `qt_context_subscriptions.py` owns derived subscription/handler endpoint sites + and ordering of native implicit versus explicit/local lexical binders. +- `qt_qml_callback_proof.py` validates the original scoped callback and source + ownership independently at the consumer boundary, using the existing QML index. +- Existing `qt_context_bindings` and `qt_qml_projection` remain their integration + and consumer boundaries; build, serialization and persistence owners are unchanged. + +All new helpers and tests retain the 300-line ceiling. Full type/shape proof is +independent of display sanitation. Diagnostic contracts and static runtime +exclusions belong to ERRORS and D21; exact artifact evidence belongs to validation. + +## Direction consumers (INC-QML-30) + +`graph_direction.logical_endpoints` owns accepted pair validation. +`affected.affected_nodes` owns query traversal and its per-call undirected index; +`qt_affected.owned_ancestors` owns bounded source promotion. `paths.load_node_link_graph` +owns typed reload and preserves invalid explicit markers for rejection. No mutable +run state, producer schema or persistence owner moves to the helper. + +The legacy dependency consumer grows from 329 to 346 physical lines, with a +permitted ceiling of 346; the loader grows from 577 to 581, ceiling 581. Owner: +dependency consumer maintainer. Reason: this focused correction preserves the +existing traversal/reload interfaces and ordering. Exit: separately characterize +and extract the complete traversal/index or typed-load responsibility under the +incremental refactoring contract. The helper, Qt consumer and new tests remain +below 300 lines. No unrelated extraction is included in this correction. + +## JSON direction publication (INC-QML-31) + +`export.to_json` owns native direction preflight before JSON writes; +`qt_export.logical_endpoints` preserves the external compatibility/rejection +interface around the shared validator. Product staging/rollback remains in +`publication.ProductPublication`; neither helper reads source or mutates the graph. +The legacy exporter grows from 1,369 to 1,373 physical lines, with a permitted +ceiling of 1,373. Owner: export maintainer. Reason: validation belongs to the existing +JSON loop and changes no other format's owner. Exit: separately characterize and +extract the complete JSON serializer while preserving write/idempotence contracts. +The 118-line Qt transport helper and both new test modules remain below 300 lines. + +## Initial update comparison (INC-QML-32) + +`watch._canonical_graph_for_compare` owns the bounded raw-update representation +normalization. The existing watch controller grows from 2,487 to 2,495 lines, +within its existing 2,550-line ceiling. Owner: update maintainer. Reason: the +existing comparison seam receives one focused policy block; orchestration and +publication ordering stay unchanged. Exit: separately characterize and extract +the complete comparison responsibility under the incremental refactoring contract. +The new 156-line repeat suite exercises production rather than duplicating its +normalization rules. + +## File-role and early direction boundaries (INC-QML-33/34) + +`qt_source_file_role.py` (32 lines) owns `generic_file_role`; normalized paths and +unique node selection remain with membership/containment. The constructor-type +owner is 236 lines after extracting `valid_source_transport`; its complete-record +authority gate remains separate. `graph_direction.py` (29 lines) owns logical +pairs and iterable candidate preflight. Watch owns diagnostic/comparison ordering. + +Watch grows from 2,487 through 2,495 (first-repeat comparison) to 2,511 physical +lines (early direction preflight), within its existing 2,550 ceiling. Owner: +incremental integration maintainer. The focused extraction exit remains comparison +and publication orchestration with unchanged ordering, retention and checkpoint +characterization. No forwarding layer, writer or ambient project state is added. + +## Final adoption projection and publication owners + +`qt_orphan_cleanup.py` (89 lines) owns exact prior import proof and pure retirement; +the updater captures proof before provenance inference. `qt_qml_event_access.py` +(115 lines) owns per-occurrence annotation and canonical partial-role upgrades. +`qt_qml_projection.py` (185 lines) owns typed-candidate selection and source-backed +operation/relationship proof. Source facts remain with their extractors; indexes +remain run-owned; no helper owns a writer, persistent cache or ambient root. + +The legacy builder grows from 2,469 to 2,475 physical lines, with a permitted +ceiling of 2,475. Owner: graph assembly maintainer. Its six-line effective growth +places typed bridge validation before generic relation admission. Extract typed +admission with characterization when the upstream assembly boundary is split; +an unrelated mechanical extraction is outside this correction. Watch grows through +2,526 (prior ownership) to 2,532 (first minted origin), below the existing 2,550 +ceiling and extraction exit. All new handwritten production/tests remain below +300 lines. This INC-QML-38 checkpoint uses AST schema 12 and Qt policy 18; +INC-QML-40 below advances the derived epoch. Older records describe their own +source/artifact checkpoints. + +## Shared compatibility checkpoint + +The [compatibility plan](../COMPATIBILITY.md) records the historical installer +command correction, filesystem-fixture transport and unavailable-CWD diagnostic +follow-up. Native Codex literal transport is deferred under DEC-CORE-01; the +contribution restores the upstream installer contract. Installation +retains hook publication ownership; watcher recovery retains ordering and its +2,550-line ceiling. Portable shell/test helpers and the Terraform regression do +not change Qt resolution interfaces, AST schema 12 or Qt policy 18. Platform and +consumer proof gaps remain distinct from the final Qt adoption artifact. + +## Canonical facade source aliases (INC-QML-40) + +`extract.py::_sf_entry` keeps cached physical source identity ahead of contained +source/definition-file publication. Written and resolved forms still supply the +existing endpoint remap keys; true external paths retain their existing policy. +The run clears the existing real-path cache and retains source-root ownership. +The facade measures 8,997 physical lines against its existing 9,010 ceiling; +separate characterized extraction remains the exit condition. + +`qt_incremental.QT_POLICY_VERSION` is 19. Existing native/Qt stamps force an +unchanged-input refresh after policy 18; syntax schema remains 12 because cached +facts precede normalization. Generic-only older API outputs need an explicit +forced rebuild when repair is required. + +`tests/test_qt_facade_path_aliases.py` (238 lines) owns real nearby aliases, +generic/native source and definition-file parity, warm cache, actual update and +foreign input controls. `tests/test_qt_alias_policy_upgrade.py` (85 lines) owns +mixed and C++-only no-change upgrades, real stamp-publication failure, prior +product retention, repaired retry and repeat. Exact evidence belongs to +traceability; current hosted acceptance remains pending. + +## Failure-safe diagnostic context (INC-QML-41) + +`qt_qml_pipeline._diagnostic_source` owns display context only. Guarded physical +containment precedes lexical `source_path` fallback; complete relative labels +over 160 characters, controls, Unicode line/paragraph separators and unprovable +context produce an empty `source_file`, without truncation. The failed join still +annotates every affected Qt/QML source and grants no target authority. +`QML_RESOLUTION_FAILED` remains the source-join code; writers retain their existing +`QML_GRAPH_PRESERVED` stage and product-cohort ownership. + +The pipeline is 106 physical lines and +`tests/test_qt_pipeline_failure_context.py` is 190. Ordinary direct and real +manual/watch regressions cover persistent OSError/RuntimeError, safe/unavailable +context, foreign transport, delimiter/ceiling rejection, prior-product bytes, +repair and repeat. Exact execution/coverage/artifact evidence and remaining +platform gaps are assigned in traceability. No epoch beyond policy 19/schema 12 +or new diagnostic code is introduced. + +## Distinct walked source identities (INC-QML-42/43) + +At the INC-QML-42/43 checkpoint, `source_identity` measured 31 lines. Its pure +`walked_relative_source` helper receives +the root and existing realpath dependency from `extract._walked_rel`. Physical +containment precedes walked relative-name selection. The facade uses the same +result for file/symbol prefix keys, target stem forms and source/definition +provenance. The facade measured 8,998 lines, within the existing 9,010 ceiling. +Qt policy 20 owns prior-19 derived refresh; AST schema 12 is unchanged. + +`tests/test_source_alias_provenance.py` (277 lines) exercises actual NTFS +junctions/POSIX symlinks, double aliases, exact IDs/call endpoints, cache replay, +directed/undirected reload, native definition/external controls, writer rename/ +deletion and failed upgrade recovery. The 241-line facade-alias fixture now uses +the actual short basename below its canonical parent; the separate 59-line +nearby-fixture regression creates real long ancestors and preserves all distance, +source and topology assertions. No production fixture response is fabricated. +The earlier 238-line size is the recorded INC-QML-40 checkpoint. + +## Physical co-owner and native spelling boundaries (INC-QML-44/45) + +The current facade measures 9,005 lines within its 9,010 ceiling. Its initial +input conversion calls `source_identity.normalize_input_source` before workers +or cache work. The stateless 125-line module also owns walked relative naming +and `resolved_source_owners`; explicit primary/shared ID ownership prevents +order-dependent overwriting. Raw source bytes, lexical declarations, remap/cache +ordering and graph-product publication retain their existing owners. + +`watch_coowners.expand_changed_coowners` (69 lines) receives only admitted +nonsemantic regular sources from the watch selector. It reads contained physical +identity, groups supported actual inode/path evidence within one call and appends +co-owners without discovery or durable state. Watch measures 2,543 lines within +its 2,550 ceiling. Combined policy 21 refreshes derived Qt products; AST schema +12 and persistence formats remain unchanged. The error catalog owns both new +bounded admission diagnostics and their recovery policy. + +Focused ordinary regressions remain below 300 lines: physical-watch 280, +admission-tier 70, native short-leaf 151, input-failure 191 and remap-owner 94. +They exercise actual NTFS junctions/hardlinks/short names, both batch orders, +supplementary Unicode, conservative import stubs, cold/warm/reload, staged +publication/API failure, retained cache/product bytes and repaired repeat. +The previous 31/8,998 measurements remain checkpoint evidence, not current sizes. + +## Manual discovery option and acceptance profiles (INC-QML-46/47) + +`cli.dispatch_command` owns the update option parser and forwards the explicit +`follow_symlinks` Boolean to the existing watch rebuild owner. `__main__.main` +owns help output. The touched legacy CLI begins at 4,966 lines with a 4,980-line +ceiling; help begins at 964 with a 970-line ceiling. CLI maintainer owns these +bounded changes. Extract update argument admission only in a separately +characterized increment if another option requires material growth; no mechanical +dispatcher move is mixed into this correction. + +The 236-line final-parity helper keeps existing callers on default false. +The 290-line discovered-alias module supplies explicit opt-in for the policy +upgrade and retains a real POSIX default-exclusion control. The focused update +module exercises actual parser, nested rebuild and publication, invalid input +retention, foreign-target exclusion and help. The membership alias fixture keeps +exact long-spelling versus lexical-symlink parser ownership alongside its cold/ +warm/ID/source/publication contracts. Policy 21, AST schema 12 and persistence +formats remain unchanged. Shared native shell transport and CI admission belong +to [INC-CORE-08](../COMPATIBILITY.md#native-shell-process-identity--inc-core-08). + +## Current-upstream and followed metadata seams — INC-QML-49/50 + +The integrated profile uses package 0.9.76, AST schema 13 and Qt policy 22. +The cache owns combined upstream Python/Qt producer compatibility; semantic +content lifetime remains separate. Discovery combines exact Qt metadata +admission with installed-Graphify exclusion under one ignore/containment gate. +CLI path lookup retains upstream exact/qualified identity before existing Qt +direction-aware traversal. Original-link serialization validates each preserved +Qt endpoint pair before the shared writer publishes. + +`extractors/qml_source_identity.py` owns stateless admitted lexical source +selection for FactBuilder, qmldir and literal project/resource/type-description +readers. The existing physical identity boundary admits inputs; the fact source +retains each separately walked contained alias and original-byte location. +Policy 22 refreshes earlier canonical-only derived products. Exact profile +evidence belongs to traceability, with the reproduced earlier failure retained. + +## Runner and metadata fixture seams — INC-QML-51/52 + +`tests/qml_ci.py` owns optional/core installs, neutral isolated smokes and +source/artifact selection. Its platform guard executes only the trusted selection +expression with real temporary filenames, preserving all install and offline +assertions. Native source selection includes `test_upstream_qt_` alongside the +existing QML/Qt prefixes; Linux wheel selection remains artifact-only. +No production graph, cache or parser ownership changes. + +The metadata growth fixture owns its size-race simulation. Physical input +identity remains owned by `source_identity`; the shared metadata reader owns +bounded read completion. The fixture must preserve actual non-size stat fields +when reporting a smaller pre-read size, so its existing rejection assertions +exercise the read boundary rather than unsupported native identity. Exact +before/after and hosted outcomes belong to the +[verified integrated checkpoint](../../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2). diff --git a/docs/qt-qml/DESIGN.md b/docs/qt-qml/DESIGN.md new file mode 100644 index 0000000000..903a72083e --- /dev/null +++ b/docs/qt-qml/DESIGN.md @@ -0,0 +1,1397 @@ +# Qt/QML implementation contracts + +The scoped exporter retains per-source-edge aggregate counts required by Qt +project membership. Count ownership remains the exporter; it visits canonical +source edges without mutating the graph and supplements baseline degree display. +This dependency does not include deferred partition recovery, startup-selection +or navigation behavior. The corrected membership scenario uses the upstream +production inspector harness rather than a deferred viewer-test module. + +## Upstream submission scope + +The [scoped upstream contribution](UPSTREAM_CONTRIBUTION.md) retains bounded Qt/QML analysis +and its source, incremental, publication and consumer contracts. REQ-QML-019 +and REQ-QML-021, D12/D16, and the generic viewer portions of INC-QML-09/13/16 +are deferred for this submission. Native Codex REQ-CORE-001-AC01/AC05 and the +broad fork repository policy are also deferred; the baseline installer and +repository policy apply. Constructor and source-containment work in INC-QML-13 +remains in scope. + +The [complete frozen fork](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4) +and its recorded 8b6c9d2 source/runner evidence retain their own revision scope. +Deferred feature sections and their exact tests below are historical evidence, +not implemented or verified contracts of this narrower contribution. A changed +contribution head requires fresh applicable checks; prior totals, skips and +hosted results do not establish those checks. + +Status: INC-QML-00 through INC-QML-07 are implemented and verified for the bounded +static profile, with revision-specific hosted source/artifact proof. +Earlier INC-QML-09 HTML selection and INC-QML-16 camera navigation/Overview +removal have local emitted-script and reviewed installed-artifact proof for their +recorded complete-fork revisions. Those generic viewer contracts are deferred +for this upstream submission. Native browser/device and other unexecuted profiles +remain gaps in that historical evidence. +[PLATFORM_MATRIX.md](PLATFORM_MATRIX.md) records declared installation lanes. +[ARCHITECTURE.md](ARCHITECTURE.md) owns ADRs and +[traceability](../../tests/TRACEABILITY.md) owns individual acceptance evidence. +Dependency-directed cache optimization and Qt runtime equivalence remain deferred. +INC-QML-10 native ownership corrections, including accepted-context source/span +deduplication, pass local source, final broad and reviewed installed-artifact checks. +INC-QML-11 implements bounded ancestor lookup. Its installed integration and +INC-QML-28 emission admission pass the recorded local source/artifact profiles; +current hosted acceptance is tracked separately in validation and traceability. +INC-QML-12 constructor source proof and the native source-containment part of +INC-QML-13 are retained, with recorded local source and installed-artifact evidence. +Its generic viewer-count feature is deferred for this submission. Missing constructor class proof +cannot be supplied by a same-name header prototype; source containment alone +does not establish a native endpoint. +INC-QML-14 membership projection and INC-QML-15 bounded overload resolution pass +the recorded local source/artifact profiles. Current integrated analysis uses +policy 22 and AST schema 13. Expanded REQ-QML-020 alias contracts retain their separately +recorded hosted corrections; an earlier profile does not verify changed behavior. + +## Ownership and dependency direction + +Detection owns corpus inclusion. The QML extractor consumes an already accepted +source file and returns source-local facts. A Qt project index consumes accepted +metadata and C++ facts. The Qt/QML resolver joins those facts after extraction; +it does not rescan ignored directories or reinterpret corpus boundaries. +The graph builder and persistence pipeline remain authoritative for output. + +New responsibilities should normally live under `graphify/extractors/` or focused +resolver modules, with thin registration calls in existing large modules. Avoid +moving unrelated language code as part of a feature PR. + +Current mutable ownership is explicit: `FactBuilder` owns one source's facts; +module/scope indexes own lookup tables for one run and borrow declarations +read-only; the relationship pass updates fresh source-owned expression sites; +the writer owns durable publication. Unchanged context dictionaries are never +updated by a join. The index API has a read-only lookup contract, without ambient +project state or filesystem discovery. + +## Source-local extraction contract + +Use the established extractor result envelope (`nodes`, `edges`, and existing +diagnostic conventions). Additional resolution facts must be explicitly carried +through per-file results, cache serialization, path normalization, merging, and +incremental reconstruction before they influence persisted output. + +Each fact needs a canonical root-relative source path, a source span, an owning +component/object scope, its literal syntax, and parser/version provenance where +that affects interpretation. Line-number conventions must match existing consumers; +validate column/byte offsets against UTF-8 and multiline fixtures. + +Identity must distinguish a file component, each object instance, members, inline +components, and names declared in separate module/version scopes. An anonymous +object can use a documented syntactic scope path; document whether moving it +changes its identity. Never key instances globally by `id: root` or type label. + +Do not treat a grouped property block as an object instance, a handler as a new +signal declaration, a string/comment as syntax, or a partial parse as an empty +successful extraction. Bindings retain a bounded expression span and references; +analysis never evaluates them. + +Implemented fields live in `metadata.qml` with `contract_version=1`. Each import, +declaration, expression occurrence and metadata record is an independent node, +so the 50-item metadata list cap cannot discard required per-file records. +Names and literal paths are case-sensitive lookup values. IDs use the complete +root-relative filename and an exact-identity digest before normalizing with +Graphify's ID utility; component/object scope keys are fixed-width hashes. +Named declaration IDs survive line insertions, while expression-site IDs include +their source occurrence and can change when text moves. + +`qml_facts.encode_metadata` adds bounded base64 `raw_values` companions for string +fields. `qml_facts.qml_metadata` returns decoded lookup values after HTML sanitation +or JSON reload; callers do not parse escaped display text. It rejects nonmapping, +over-50-field, nonstring, invalid base64/UTF-8 or over-512-encoded-byte transport. +The display metadata is not a second resolver owner. Malformed transport becomes +analysis failure/publication rejection; it cannot silently select a different name. + +## Resolution contract + +Build indexes from explicit module/type declarations, supported project metadata, +documented implicit directory visibility, and configured import roots. Track +alias, version, singleton, registration, and object/component scope separately. + +Resolution returns one of resolved, ambiguous, external/unavailable, dynamic, or +unsupported. Only one valid visible candidate may produce a resolved endpoint. +Ambiguous and dynamic candidates keep evidence and a reason instead of an +arbitrary edge. A label match across the repository is insufficient. + +The default analysis uses source and metadata already in the accepted corpus. +Optional SDK import paths or generated `.qmltypes` need an explicit configuration, +provenance, root/ignore policy, and version. Do not scan the machine's Qt installation +implicitly or infer exposure solely from C++ inheritance or `Q_OBJECT`. + +Implemented entry points: + +| Interface | Input/output and owner | +| --- | --- | +| `extract_qml(path, *, root=None)` | One accepted QML source to declarations/expression facts, edges and diagnostics; no cross-file lookup | +| `extract_qmldir(path, *, root=None)` | One accepted literal manifest to independently owned module/export/import/dependency records | +| `build_qml_index(nodes, edges, *, root, import_roots=None, native_index=None, project_index=None)` | Per-run `QmlProjectIndex`; accepted source paths and declaration/provider tables only | +| `resolve_type(file, component_key, name)` | Qualified/document-local type lookup; `Resolution(status, target_id, reason, evidence, candidates)` | +| `resolve_member(file, component_key, object_scope_key, name, *, lexical_names=())` | Component IDs, own/root/known inherited members and typed continuations; no global labels | +| `follow_member(target_id, parts)` | Continue a proven object or typed-property target; alias chasing belongs to `RelationshipLookup` | +| `module_script(...)`, `directory_script(...)` | Script namespaces use a separate role from object types | +| `resolve_qml_project(per_file, all_nodes, all_edges, *, root, import_roots=None, native_index=None, project_index=None)` | Append fresh source-owned import/type sites and resolved edges; do not modify borrowed context | +| `collect_qml_scripts(paths, per_file, *, root)` | Add QML-owned overlays only for admitted, referenced scripts and admitted literal dependencies | +| `resolve_qml_relationships(per_file, all_nodes, all_edges, *, root, import_roots=None, native_index=None, project_index=None)` | Update fresh read/call/alias/handler status, append compatible inferred target edges | + +Import roots default to the explicit scan root. CLI/watch and generated assistant +AST guidance inspect the ordered project-relative `GRAPHIFY_QML_IMPORT_ROOTS` +JSON list and pass it explicitly through `extract` to the index. Direct API callers +pass `qml_import_roots`; they do not inherit that environment setting implicitly. +Ambient SDK paths and remote imports are excluded. Current layouts include unversioned providers and explicitly requested +`.major`/`.major.minor` directories. An observed available manifest version precedes +selection of the latest compatible type export. URI aliases stay document-local; +`qmldir import` adds visibility while `depends` does not. Internal/singleton checks, +missing versions and duplicate eligible targets retain explicit reasons. `prefer` +resource redirection remains unsupported by the `qmldir` resolver. The separate +Qt resource index resolves accepted literal QRC/CMake URL mappings. + +`qmldir` is capped at 1 MiB/10,000 meaningful records. Lookup caps include 32 module, +inheritance or alias depth, 32 parts per member-continuation API call, 256-character +collected reference paths, 1,024 module-query steps and 50 candidate/evidence +entries. Truncated candidate lists are display summaries, never permission to +choose a winner. + +Expression resolution respects parameters, block/catch/loop locals and hoisted +`var`; a per-use shadow flag remains authoritative if lexical display names are +capped. Reassigned local callables and computed targets remain dynamic. Pure +assignment destinations are not reads, but computing receiver/index addresses +and augmented assignment retains dependencies. Alias chains/cycles use bounded +lookup and never evaluate property getters. Current `this` denotes the source +owner; `parent` requires explicit static-parent evidence. + +Name-based `Component`, `delegate` and `sourceComponent` barriers conservatively +separate body IDs. They do not prove a Qt runtime creation context or model roles, +and custom names can therefore be conservatively unresolved. Attached handlers +and runtime-selected `Connections.target` remain unresolved. For supported +`Connections`, mixed legacy/function styles mark function-style handlers ignored; +property change handlers get distinct inferred notify-signal facts. + +Classic JS directive masking preserves byte positions. Supported `.mjs` exports +control importer visibility; unsupported re-exports do not expose matching local +functions accidentally. QML-owned script overlays have distinct identities from +generic JS declarations, and shared scripts gain no arbitrary QML importer's ID +namespace. Overlay parsing is bounded at 5 MB/100,000 AST nodes/depth 256 and 256 +enriched files. No build tool, script, getter or Qt engine is executed. + +## Graph projection and compatibility + +Project accepted facts into existing node/file types and relation/context fields +where their semantics fit. Cross-file resolution remains inference unless the +existing evidence rules justify an extracted relation. Preserve the literal +reference's source span and the definition's provenance separately. + +The baseline builder uses simple graphs. Before bindings and signals share +endpoints, establish a nonlossy representation for distinct facts and confirm +JSON/export/query round trips. A global migration to a multigraph is a separate +architecture decision; it must not be smuggled into a language-parser PR. + +The implemented projection uses one source-owned node per occurrence. Declaration +containment is `EXTRACTED`; resolved site-to-target edges are `INFERRED`. Actual +contexts are `qml_import_resolution`, `qml_type_resolution`, `qml_binding_read`, +`qml_alias_target`, `qml_signal_subscription`, `qml_property_notify_signal`, +`qml_signal_emit`, `qml_js_call` and `qml_script_call`. Reads use `uses`, aliases +and subscriptions use `references`, and proven function calls use `calls`. +Signal emission uses a dependency to its declaration and does not invent a +synchronous call to handlers. Status/reason/evidence on unresolved sites remains +queryable without a guessed endpoint. + +`qml_projection.allows_qml_script_edge` admits only the dedicated typed QML script +exception through the existing cross-language guard: import proof names the exact +accepted script file/path/role, and calls target QML script-function overlays. +Generic JS callers and unrelated C++ edges keep their existing rules. On reload +of an undirected graph, `load_node_link_graph` restores QML edge `_src`/`_tgt` +from serialized endpoints; consumer direction uses these values, not iteration +order. Build/JSON preservation does not establish all INC-QML-07 presentation support. + +Any new fields need compatible defaults, serialization tests, and a cache/version +decision. Remapping IDs must also remap resolver-fact keys, references, and provider +indexes, not just node IDs and edge endpoints. + +## Qt events and bidirectional object boundaries + +REQ-QML-008 supplies the registered C++ surface to QML. REQ-QML-016 adds native C++ +signal/slot semantics independently of the QML parser. REQ-QML-017 follows C++ +consumers of QML objects and literal context/initial-property providers back to +their scoped declarations. These are implemented bounded source contracts, +with acceptance evidence and remaining consumer/release gates in traceability. + +Retain source-owned emission, connection, disconnect, load, lookup and member +access sites. Existing `contains`, `uses` and `references` relations project +their endpoints; `metadata.qt` retains endpoint roles, signatures, conditions, +declared connection flags, source spans and `bridge_direction`. The implemented +collectors/resolvers retain their exact per-role contexts; `qt_signal_emit`, +`qt_connect_signal` and `qt_cpp_qml_invoke` are examples. Source-owned sites and +endpoint-role nodes distinguish connect, disconnect, load, lookup, property +read/write and provider exposure through the existing simple graph. + +An emission refers to a declared signal. A connection refers to its sender signal +and receiver callable/signal, with a separate context object for functors when +supplied. Direct slot calls and calls inside a connected lambda remain ordinary +calls. Do not turn event propagation into caller-to-slot `calls` edges. Support +member pointers, compatible ordinary C++ receiver members, overload selectors, +signal-to-signal, lambdas/functors and legacy `SIGNAL`/`SLOT` signatures only with +source/type evidence. Private slots can be meta-object connection endpoints even +though direct invocation follows C++ access rules. That does not automatically +make private slots a QML API. Preserve declared Auto/Direct/Queued and other +flags without claiming runtime thread affinity, registration success or delivery +order; retain the documented UniqueConnection limitation for functor targets. +See [Qt signals and slots](https://doc.qt.io/qt-6.8/signalsandslots.html) and +[QObject connections](https://doc.qt.io/qt-6.8/qobject.html#connect). + +Reverse lookup requires a bounded provenance chain: supported literal loader, +same engine/component/view, uniquely identified root or created object, optional +literal objectName lookup, then a declared member/signature. Initial profiles +cover engine `load`/`loadFromModule` with `rootObjects`, component loading/creation, +and QQuickView `setSource` with `rootObject`. Relative URLs need a known URL base; +they are not automatically relative to C++ source. A QML `id` is not a findChild +objectName. Duplicate names/roots, pointer escape/reassignment and computed names +remain unresolved or conditional. Property read/write and meta-object invocation +retain API variant and access direction; invocation is dispatch evidence rather +than proof of synchronous execution. A declared QML signal connects to C++ using +the same connection-site contract. See +[C++ interaction with QML](https://doc.qt.io/qt-6.8/qtqml-cppintegration-interactqmlfromcpp.html) +and [QQuickView](https://doc.qt.io/qt-6.8/qquickview.html). + +Literal setContextProperty, typed setContextObject and named initial-property maps +retain the provider and receiving context/component. Respect known explicit +property precedence and context boundaries. They cannot add a repository-global +name. Arbitrary provider dataflow and runtime replacement stay deferred. Both +directions and native event facts must survive simple graph projection and +consumer output; changed QML APIs must invalidate unchanged C++ consumers. + +## Incremental lifecycle and failures + +QML and Qt metadata extraction bypass AST cache reads and writes. A Qt analysis +context also bypasses native syntax cache; plain C++ retains portable syntax +caching. Generic JS retains its existing cache-bypass rules, and QML overlays are +constructed for the current run. INC-QML-06 refreshes the accepted code corpus after +provider/script/source changes and parser/import/admission-configuration changes. +Dependency-directed reuse is a later optimization, not a completion prerequisite. + +`require_complete_qml` rejects failed, partial or omitted source contributions and +script/join failure markers before graph reconciliation and publication, including +force/partial-output requests. Prior graph/manifest/report output bytes remain +intact. Unsupported subfolder scoped-ID rebasing is rejected separately. Earlier +scan/stat bookkeeping is outside the durable-output guard. See [ERRORS.md](ERRORS.md) +for diagnostic ownership and recovery, rather than treating unresolved coverage +as parser/write failure. + +File-content hashes alone cannot validate links dependent on module manifests, +C++ registrations, resource aliases, import configuration, or parser upgrades. +The analysis checkpoint covers installed parser/package versions, Qt fact/policy +versions, ordered roots, accepted paths, ignore files and caller exclusions. +Changed providers/configuration trigger fresh resolution even without a QML edit. + +The implemented fallback rebuilds already accepted inputs and removes stale +derived facts after deletion, rename, duplicate-provider or ignore changes. +It never rereads deleted/ignored inputs or expands configured lookup roots. +An unavailable parser cannot create a successful empty cache entry. The checkpoint +is committed only after graph/manifest publication; failed analysis preserves +prior graph/manifest/checkpoint bytes. Unsupported scoped subfolder rebases reject +before publication. Dependency-directed optimization must preserve these contracts. + +## Verification boundaries + +Test production extractor, resolver, build, serialization, update, and consumer +interfaces. Fixture output must be hand-checked against source facts, not generated +from the implementation under test. Compare normalized output across cold/warm, +full/incremental, relative/absolute, and Windows/POSIX cases. + +Each feature increment updates [requirements](../REQUIREMENTS.md), +[traceability](../../tests/TRACEABILITY.md), support documentation, and any changed +design decisions in the same PR. Dynamic object creation, arbitrary plugin code, +full preprocessing, and arbitrary build-script evaluation remain explicit limits. + + + + +## Implemented native Qt contracts (INC-QML-04) + +`qt_qml_pipeline.resolve_qt_qml` owns scratch integration after final generic C++ +canonicalization. Exposure, events and object access have focused collectors and +per-run indexes. Every native fact uses recursive, bounded raw semantic transport; +malformed or oversized transport fails rather than becoming a guessed endpoint. +All target IDs refer to accepted canonical declarations or independent owned facts. + +Native emissions use `uses`/`qt_signal_emit`. Connection/disconnection endpoint +roles use `references` and their own source sites. Reflective QML method intent +uses `calls`/`qt_cpp_qml_invoke` only for an evidenced declared function; the distinct +context and `operation=invokeMethod` retain its reflective mechanism. This category +records source intent and does not assert successful runtime invocation or delivery. +Qt connection flags and conditional registration are source configuration evidence. + +Literal loader URLs establish source components; source-local handles model +roots and objectName lookup. QML id is never an objectName. The follow-up audit +found that reflective member lookup can fall back to the QML root, child lookup +can cross the receiver subtree, and distinct same-named engines can share a +provider at the audit baseline. INC-QML-17/18 correct those bounded ownership +and declaration forms; the ordinary regressions and installed artifacts prove +them independently. Static reflection API shadows remain INC-QML-22. +Duplicate exposures retain uncertainty. All joins borrow prior corpus dictionaries +read-only. `qml_failures` and `qt_failures` both enter the publication integrity gate. +No force/partial option can publish an incomplete Qt overlay. +That integrity gate rejects recorded analysis failures; it does not detect a +semantically incorrect target labeled resolved. The independent persisted-edge +probes in [FOLLOWUP_AUDIT.md](FOLLOWUP_AUDIT.md) cover that distinction. + + +INC-QML-05 activates the internally packaged metadata readers at discovery/dispatch. +After native overlays borrow final C++ IDs, QtProjectIndex supplies real source +membership to QtQmlBridgeIndex and both QML resolvers. Resource aliases resolve +only accepted targets. Generated/source disagreements attach to the fresh source +result as warnings; partial metadata participates in the publication failure gate. +Versioned Qt/QML namespace-shaped facts bypass generic C# namespace merging, +retaining distinct provider and repeated resource identities. + + +INC-QML-06 owners: qt_incremental computes accepted-input refresh/cache policy without +scanning directories; qt_analysis_state inspects accepted ancestors and produces a +frozen checkpoint. CLI/watch own successful publication and checkpoint commit. +Each parallel worker receives native-cache bypass explicitly; no mutable global +root or policy is introduced. Borrowed context facts remain read-only. Native Qt +scoped facts reject subfolder path-only rebasing; callers update the project root. + + + +## INC-QML-07 consumer and assistant contracts + +Search projects bounded decoded semantic fields under `graphify_qt_qml` rather +than assuming nested metadata is searchable. Query/explain/path and installed MCP +consume the same source-scoped persisted facts; logical endpoint direction survives +default undirected JSON reload. Affected traversal promotes source-owned sites +through current source-owned containment, respecting the selected dependency +relation. Normally owner, site and containment fact share a source file. A +canonical C++ header declaration may own an implementation site only when its +accepted `definition_file` matches that site's file, the declaration is callable, +and extracted Qt containment proves the exact owner ID, child endpoint and +original span. A foreign file or unproven definition cannot establish ownership. +Emission/connection evidence remains distinct from ordinary direct calls. + +HTML exposes source/event/access evidence and uncertainty; coverage counts +unresolved sites separately from resolved-edge confidence. JSON is the canonical +semantic backup. Other formats have individual retention/omission contracts in +[EXPORT_MATRIX.md](EXPORT_MATRIX.md), including undirected GraphML direction loss +and presentation-only Canvas/Obsidian/SVG limits. Driver payload tests do not prove +live database insertion/readback or stale-record cleanup. + +Generated assistant extraction reads the trusted root and inspects configuration +without writing state. It passes `qml_import_roots` and `refresh_native` explicitly, +then calls the production integrity gate before AST JSON publication. CLI/watch +alone own the final analysis checkpoint. Authoritative fragments and frozen +generator guards are validated together; artifacts are regenerated rather than +edited independently. Hosted proof must be rerun for the final INC-QML-07 head. + +## Valid native syntax recovery during adoption + +`extractors/qt_cpp_syntax.py` retains original source/lexical views and delegates +only the recovery parse view to `extractors/qt_cpp_compat.py`. The caller supplies +its bounded parser and iterative traversal; the helper owns no roots, files, +mutable state, cache or persistence. An AST-confirmed empty parameter default is +omitted only in the equal-length parse view. A standalone Qt `Q_UNUSED` statement +uses the macro's existing bytes for its void cast and supplied semicolon while +preserving evaluated argument/call bytes. Original facts, declaration types and +locations still use the original source. Comments, strings, directives, local +macro overrides, ordinary declared/qualified functions, value contexts and +malformed controls cannot authorize recovery. This corrects supported syntax; +it does not relax the graph integrity guard or infer runtime targets. + +The lexical owner recognizes C++ preprocessing-number tokens before quote +masking. Numeric digit separators therefore cannot obscure later Qt annotations +or signal sections. Numeric bytes remain unchanged and the parser validates their +syntax; malformed separators do not acquire a successful recovery. Character, +string, raw-string and comment exclusion retain their existing ownership. + +The same-version development upgrade uses a new AST cache schema and Qt policy +epoch to prevent reuse of pre-correction declarations/graphs. Conservative native +refresh relevance includes the accepted syntax candidates; unrelated plain C++ +keeps its portable cache behavior. Epoch/schema migration invalidates obsolete +AST artifacts under upstream cache ownership; compatible current cache data, +semantic cache and prior accepted graph/manifest/state remain protected on a +failed candidate. The graph writer still commits analysis state after successful +publication. See the exact regression/evidence inventory in IMPLEMENTATION.md. + +## HTML community-view ownership + +Upstream disposition: D12 and REQ-QML-019 are deferred. This section preserves +the complete fork's generic presentation design; these helper owners and viewer +behavior are absent from the narrower contribution. Retained Qt HTML payload, +tooltip, search and omission contracts remain separate. + +[frozen html_communities owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/exporters/html_communities.py) validates complete, disjoint membership for a +large aggregate view. Missing or invalid presentation grouping is recovered on +an export-local graph copy through the existing clustering interface. It fills +missing labels through the existing hub labeler and discards labels tied to a +replaced partition. Small full-node views preserve their existing membership. +Neither grouping recovery nor HTML export changes canonical source facts, +analysis sidecars or graph JSON. The atomic writer retains the previous HTML +until successful replacement; CLI status reflects actual publication. + +The HTML viewer starts with Select All checked and all exported view nodes and +edges active in its datasets before layout. Large graphs keep their complete +labeled community aggregate and supported cap; startup selection does not +replace that view with raw full-source rendering. INC-QML-16 removes the Overview +button, its reset function/state and ten-community ranking. Community filters, +search, Select All and Select None keep their existing selection roles. Full exported semantic +payloads remain in the artifact. Community names describe structural hubs; +canonical architecture and design documents retain intentional component +ownership. View selections, camera and search are temporary browser state, with +no saved-view persistence. Earlier checked-default/Overview AC04 proof belongs to +its prior revision; current removal/coexistence checks pass at emitted-script and +reviewed installed-artifact boundaries. [Earlier selection proof](VALIDATION.md#inc-qml-09-current-selection-policy) +and [current removal/navigation proof](VALIDATION.md#inc-qml-16-middle-mouse-navigation-and-overview-removal) +retain their revision boundaries. Native browser/device and other-platform/hosted +checks remain separate unexecuted evidence. + +## Native ownership authority (INC-QML-10) + +The correction adds `is_definition` to version-1 Qt class facts, derived solely +from the parsed class/struct body's presence. Forward declarations remain owned +facts with their existing canonical IDs and spans; they do not supply complete +class authority to out-of-line binding or native event type lookup. The mapper +and event index require a unique accepted complete definition, preserve ambiguity +between distinct complete definitions, and leave forward-only or unsupported +evidence unresolved. The flag does not establish QML exposure or a runtime object. + +`qt_cpp_mapping.CppMapping` maps out-of-line methods using their exact accepted +canonical `definition_file` and `definition_location`, original source span, +callable identity and class-containment evidence. Header provenance remains the +declaration owner; the implementation owns its emitted/access source sites. +These two locations are complementary evidence, not permission to bind a method +in any matching file or namespace. Do not delete forward declarations, merge IDs +by label, relax signature/owner checks or fabricate calls to remove isolated nodes. + +`qt_cpp_exposure` transports definition authority and carries it through global +binding; `qt_event_index` consumes it when establishing class-qualified endpoints. +Borrowed complete-class records also transport the producer's exact `source_file` +and original `span`. A fresh AST record and an accepted unchanged-context fact +for one canonical body share that identity and count as one definition. Copying +only ID/name/definition authority loses the body key and creates false ambiguity. +Inputs stay read-only; deduplication cannot merge distinct body locations or +compensate for missing provenance by a name guess. +Indexes remain per-run and borrowed canonical nodes remain unchanged. At the +INC-QML-10 snapshot, Qt policy epoch 3 forced same-package analysis refresh for +old ownership facts and AST cache schema remained 6. INC-QML-12/13 subsequently +use policy 5/schema 7 with the same graph/manifest/checkpoint publication ordering. +Direct/pipeline/build/reload context and ownership tests pass locally. This +internal body-identity transport correction changes no persisted fact contract; +policy epoch 3 and AST schema 6 were appropriate for that revision. Final broad and reviewed +installed-artifact proof passes locally; see +[INC-QML-10 validation](VALIDATION.md#inc-qml-10-native-source-ownership). + +## Inherited-signal lookup (INC-QML-11) + +`QtEventIndex` owns a run-local, cycle-safe lookup over at most 32 uniquely +accepted complete classes. Canonical base names and their source-owned access +come from the native class producer. Each branch stops at its first same-name +declaration before role, signature and visibility filters. An ordinary member +therefore shadows an ancestor signal. Repeated paths to one canonical declaring +member deduplicate; different declaring classes remain ambiguous even when a +selector matches only one branch. Missing, duplicate, partial, conditional, +corrupt or over-limit ancestry cannot lend endpoint identity. + +External typed member pointers require public inheritance as well as the existing +member-access checks. Each comma resets class/struct default base access; comments +and `virtual` spelling do not authorize visibility. Legacy meta-object lookup and +owning emission retain their separate access rules. The index adds no runtime +conversion, object-instance, scheduling or thread-safety claim. + +Policy 13 refreshes derived Qt facts at unchanged source/package versions. AST +schema remains 9 for this increment; the later constructor/header migration owns +schema 10. Source, JSON/query/affected and actual manual/watch mutation, retention +and repaired retry checks pass locally. Installed integration remains pending. +An unchanged explicit emission whose declaration disappears exposed a collector +admission gap; INC-QML-28 owns that correction, independently of endpoint lookup. + +## Constructor source proof (INC-QML-12) + +The generic C++ producer augments constructor prototypes that tree-sitter emits +as class-body `declaration` nodes. `cpp_constructors.py` owns bounded exact +qualified names, original-byte spans, normalized parameter-type signature hashes +and ambiguity. `metadata.cpp_class` version 1 records complete-body authority; +`metadata.cpp_constructor` version 1 records declaration/definition identity and +class-binding outcome. Literal names use bounded base64 transport so display +sanitation cannot alter identity. No native Qt role or runtime object is implied. + +The engine hook runs only for accepted C++ inputs; the existing declaration/ +definition canonicalization calls the focused binder before its normal merge. +A unique complete class, matching unambiguous constructor prototype, exact scope, +signature and accepted source-backed method containment authorize a class join. +Supported singleton definition IDs are retained; missing header prototypes use +the existing ID formula. Unsupported overload collisions can use the baseline +ID disambiguation path and do not establish distinct overload identity. +Qualified namespace definitions need not merge into a differently +keyed header symbol. Both accepted identities retain their own provenance. + +Collapsed overloads, missing/malformed facts, foreign scope, mismatched signatures +or conflicting owners cannot authorize a class join. Signature normalization +retains word boundaries, excludes parameter names/defaults and does not resolve +aliases or compiler conversions. Unsupported delegation/overload cases remain +unknown rather than gaining a guessed class. `constructor_class_authorized` +allows consumers to reject class authority without losing an independently +accepted source callable. + +Native mapping consumes the exact body candidate and this guard. An unbound or +legacy out-of-line constructor cannot be replaced with a same-name header +prototype. Its source callable may contain observed sites, but `this` cannot +acquire a native type from that spelling alone. Independently typed local +handles and literal QML access retain their own evidence. + +## Source containment and aggregate counters (INC-QML-13) + +`qt_cpp_exposure._class_facts` keeps accepted class containment first. Without +Qt class authority, a member occurrence may instead be contained by its uniquely +resolved canonical callable; a local class occurrence may be contained by its +accepted enclosing callable. The fallback requires an actual callable node, +never a class-like target or global label. Class IDs, native roles and unresolved +class status remain unchanged. Unknown/conflicting callable evidence adds no +link. Borrowed nodes and edges remain immutable. + +`qt_source_containment.attach_qt_file_sites` adds file-to-occurrence `contains` +edges with context `qt_source_file` when callable ownership remains unknown. +It reuses a unique accepted code file node with exact in-root path, filename +label, L1 location and no callable/class/semantic role. Fresh AST identities +are supplied explicitly before final facade tagging; borrowed context requires +its persisted AST marker. Duplicate, foreign or unmarked borrowed candidates +fail closed. Original spans and endpoint direction survive publication. File +context does not populate `owner_id`, class identity or semantic resolution. + +The following full generic aggregate-inspector contract remains historical under +deferred REQ-QML-019. Its internal/external source-count projection is retained +as the REQ-QML-020 membership prerequisite; connected-community labels and +isolate explanations remain deferred. Native source containment remains retained. +The HTML aggregate counts each canonical source-graph edge once as internal to +a community or external at both incident communities. Distinct neighboring +communities remain a separate count. Canonical self-loops and parallel/directed +edges are counted as represented by the input graph; serialized duplicate +occurrences are not additional edges. Counts add no aggregate self-loops or +synthetic relationships. The frozen inspector distinguishes closed connected groups +from actual isolates. A pre-aggregated caller without source counters displays +unavailable counts; ordinary source-node Degree and Select All remain unchanged. + +The reviewed INC-QML-12/13 artifact uses AST cache schema 7 to retire older C++ +producer facts at the same package version. Its Qt policy epoch 5 invalidates +derived constructor/source-ownership facts, including +the intermediate policy-4 analysis produced before source-file containment. +Graph/manifest/checkpoint persistence owners and publication order are unchanged. +INC-QML-14 subsequently uses Qt policy 6 with schema 7; this earlier artifact's +proof remains tied to its constructor/source-containment revision. +Migration discards incompatible AST cache entries; failed analysis retains prior +graph/manifest/root/Qt state and requires a corrected retry. This is different +from promising incompatible old-cache retention. + +### Legacy file cohesion exceptions + +Inherited dependency exception: `graphify/extractors/markdown.py::_active_scan_root` +imports the extraction facade and reads `_XAML_ACTIVE_EXTRACT_ROOT`, unchanged +from imported `0b60d47`. Owner: Markdown extractor maintainer; permitted scope is +that existing helper only, with no new consumers or ambient state. Exit: a separate +characterized refactor passes scan root explicitly while preserving vault-link +fallback, cache lifetime, direct-extractor behavior and parallel-worker semantics. +Review this exception before changes to either owner; it does not apply to Qt/QML +extractors or authorize facade imports elsewhere. + +The following measurements belong to the INC-QML-13/16 checkpoint. Current +submission owners and ceilings are recorded in +[UPSTREAM_CONTRIBUTION.md](UPSTREAM_CONTRIBUTION.md); the camera helper and its +injection are absent from the scoped exporter. + +| Owner/file | Historical measured size / permitted ceiling | Rationale and extraction exit | +| --- | --- | --- | +| HTML exporter maintainer: `exporters/html.py` | 789 / 810 physical lines | Existing embedded template and recursive aggregate projection share serialization. INC-QML-16 removes Overview and injects the isolated camera helper through a narrow seam, retaining this ceiling. Extract aggregate projection or template responsibility in a separate characterized viewer increment when the next cohesive change requires it. | +| Generic extractor maintainer: `extractors/engine.py` | 7694 / 7700 | Five-line constructor producer hook only; domain logic belongs to `cpp_constructors.py`. Continue the upstream mechanical migration sequencing rather than mixing unrelated language moves into this fix. | +| Generic resolver maintainer: `extractors/resolution.py` | 3974 / 3980 | Six-line pre-merge/guard hook only; the helper owns constructor decisions. Extract the existing declaration/definition responsibility as a separate characterized increment when upstream sequencing permits. | +| Extractor facade maintainer: `extract.py` | 9009 / 9010 physical lines | One existing canonicalization call supplies raw calls to the same resolver owner; preserve dispatcher direction and extract a cohesive facade responsibility separately under the migration playbook. | +| Cache maintainer: `cache.py` | 1782 / 1783 physical lines | Schema constant/comment changes only; cache ownership and migration ordering stay intact. No responsibility extraction is needed for this epoch update. | +| Export/Qt integration test maintainers: `tests/test_export.py` | 1377 / 1377 physical lines | INC-QML-16 adds listener registration to the existing inspector harness without dispatch, camera math, swallowed errors or changed inspector assertions. New behavior stays in the focused navigation suite. Exit through coordinated upstream inspector-harness extraction that preserves production-script assertions. | + +New handwritten constructor/source-link/viewer modules and tests must remain +below 300 lines. Verification measurements and any remaining platform/system +gaps belong to VALIDATION.md and tests/TRACEABILITY.md. + +## Metadata membership projection (INC-QML-14) + +Status: **Locally complete; Verified within the bounded static source profile**. +QtProjectIndex resolves accepted +source/resource literals for module/load/access lookup. The additional projection +reuses those accepted facts and canonical endpoints rather than reading a declared +path or evaluating a build condition. Functioning lookup and truthful internal +community counts do not establish this new acceptance evidence. +Architecture decision [D15](ARCHITECTURE.md#d15--project-membership-is-independent-of-component-use) +separates membership from component use and Qt runtime behavior. + +`qt_project_membership.resolve_project_memberships(results, nodes, edges, *, root, +project_index, fresh_ast_ids=())` owns the derived joins after canonical source +identities exist. It returns `(derived_nodes, derived_edges)` and replaces only +its own scratch/per-file site mechanisms; borrowed declaration/context dictionaries +remain immutable. The pipeline publishes through starting node identities and +edge-object identities, explicitly including fresh same-ID membership replacements. +Replacing a borrowed site cannot shift append offsets or publish unrelated context. +The admission comparison resolves each already supplied input against the +canonical root, so short paths and parent-directory aliases cannot reject the +same contained file. This identity check does not rewrite lexical source facts, +literal lookup values or the shared `source_path` helper. Foreign input and +declaration/input conflicts still fail the owning metadata guard. Resolving path +identity reads filesystem path metadata, not target source content or new corpus +inputs. The INC-QML-39 checkpoint retains policy 18/schema 12; INC-QML-40 advances +derived policy 19 without changing the AST schema. +The helper and its focused test +module stay below 300 physical lines. Reader syntax, corpus admission, existing +indexes and graph persistence remain in their existing owners. + +Each declaration owns an independent `metadata.qml` contract-version-1 +`membership_resolution` site. Its bounded fields include `declaration_id`, +`declaration_kind`, original `span`, `source_kind`, `module_key`, resource alias or +logical URL context, `status`, `reason`, `evidence` and `candidates`. Evidence and +candidates each retain at most 50 IDs. A resolved site also records `target_id`. +Site identity is deterministic for the same accepted declaration/input and does +not depend on an absolute checkout path. It retains the declaration's source +provenance without changing the raw declaration's metadata. + +| Logical edge | Relation / context / confidence | Authority | +| --- | --- | --- | +| Declaration to membership site | `contains` / `qt_membership_site` / `EXTRACTED` | The accepted declaration owns this observed membership attempt | +| Resolved build-source site to file/component | `references` / `qt_project_source` / `EXTRACTED` | Supported literal source membership establishes a unique accepted canonical endpoint | +| Resolved resource-alias site to file/component | `references` / `qt_resource_membership` / `EXTRACTED` | Supported literal alias/path evidence establishes a unique accepted canonical endpoint | + +Separate sites prevent repeated declarations and parallel source/resource +mechanisms from collapsing in the existing graph representation. Preserve typed +logical endpoints and original source locations through default-undirected and +directed build/JSON transport. A published source reference establishes static +membership, not a runtime import, QObject relationship or architectural module. + +Missing, duplicate, conditional, generated or out-of-root targets retain explicit +unresolved/unsupported site status and bounded reason/evidence without a target +edge. The helper does not open a file, expand the corpus, run a build/QML/plugin, +or choose by filename label. Existing module/resource/loader lookup results are +unchanged. Ordinary coverage follows `QML-RESOLVE-001` semantics; invalid transport +or an unexpected join failure propagates into the existing +`QML_RESOLUTION_FAILED` publication guard rather than adding a second writer. + +Qt policy epoch is 6; AST cache schema remains 7. Refresh derives new +membership sites from accepted inputs at the same package version and retires +stale target edges after source/resource edits or removal. Existing publication +ordering retains graph/manifest/root/Qt state on genuine failure even under force; +corrected retry and no-change repeat use the normal lifecycle. These migration, +production consumer and reviewed installed-artifact outcomes pass locally; exact +evidence is recorded in [validation](VALIDATION.md#inc-qml-14-membership-projection) +and [traceability](../../tests/TRACEABILITY.md#project-membership-projection). +The helper/source-test/pipeline/lifecycle-test owners are 187/283/85/267 physical +lines, respectively, all below the 300-line handwritten-file ceiling. Prior +policy-5/schema-7 proof remains historical for INC-QML-12/13. No browser, new +hosted, other-platform or executable Qt evidence is inferred from local tests. + +## Exact overload identity (INC-QML-15) + +The generic producer owns versioned constructor signatures before node collapse. +`cpp_constructor_signature` canonicalizes the admitted AST parameter shape and +limits signatures to 64 parameters and 360 UTF-8 bytes (480 encoded bytes). +Builtin, self-class and literal QObject types are supported; named aliases, +templates/dependent types, function pointers, arrays, variadics and conditional +syntax retain occurrence identity without exact join authority. Value-only CV, +parameter names/defaults and builtin synonyms normalize; pointer/reference +shape and pointee CV remain significant. Adding/removing another overload never +changes an accepted constructor ID. + +`cpp_constructor_binding` joins one unique complete class, one exact declaration +and one definition, preserving original byte spans and definition-file/location. +Duplicate same signatures/bodies, corrupt transported facts and incompatible +owners reject native class authority. Inline and out-of-line Qt mapping selects +the exact accepted signature and occurrence span. A delegating initializer does +not supply an inferred constructor-call target. + +The direct producer stamps bounded `cpp_constructor_types` on its source file: +literal include delimiters/paths and namespace/class QObject shadows with original +spans and source size. The corpus validator walks only admitted quoted dependencies +(maximum 128 files, 50 entries per list); missing/corrupt/ambiguous proof rejects +SDK signature authority. Potential local angle-include candidates also reject; +configured compiler include search is not guessed. Existing `_signature` remains +the ordinary-member compatibility normalizer used by `cpp_member_identity`; +constructor identity uses the new exact owner. No second mutable cache or parser +owner is introduced. + +[D20](ARCHITECTURE.md#d20--constructor-signatures-authorize-identity-and-joins) +defines schema 10/policy 14 migration. Real manual/watch signature edits/removal, +C++-only mismatch and header repair, original BOM/CRLF/Unicode spans, reload/query/ +affected, malformed source and actual read-only/late OS publication failure with +retry have dedicated regression coverage. Installed integration remains pending. + +## Semantic correction acceptance matrix (INC-QML-17–20) + +Status: implementation and local revalidation in progress. These corrections +retain the existing extraction, scratch-join and publication owners. No corpus +code, build hook, QML engine or plugin executes. Receiver members belong to the +receiving object and accepted type; source construction and search depth constrain +child lookup. Engine/provider joins require declaration identity. Native aliases +require lexical declaration authority. Literal loaders share accepted project +component resolution without making runtime execution claims. + +| Increment / acceptance | Positive boundary | Rejection and failure boundary | +| --- | --- | --- | +| INC-QML-17 / REQ-QML-017-AC02/AC04 | Own/inherited members, receiver-relative recursive/direct children, exact byte spans | Root lexical fallback, siblings, duplicates, dynamic options and unsupported/reparented trees cannot supply a target | +| INC-QML-18 / REQ-QML-017-AC03/AC04 | One exact source engine/component declaration and typed provider | Disjoint/nested names, reassignment, conditional exposure, aliases and unknown lifetime cannot establish shared identity | +| INC-QML-19 / REQ-QML-008-AC01/AC03; REQ-QML-016-AC01–AC04 | Direct native types and proved literal aliases retain canonical endpoints and distinct event mechanisms | Shadowed globals, cycles, conflicts, unsupported aliases and incompatible endpoint roles cannot satisfy a join | +| INC-QML-20 / REQ-QML-017-AC01/AC04 | Literal engine URL constructor and component loadUrl/create retain component/root provenance | Computed URLs, unsupported overloads, duplicate/reassigned loaders and unavailable resources remain uncertain | + +Each correction requires raw JSON direction, query/affected, cold/warm and actual +manual/watch edit/removal parity. Policy upgrades must reanalyze unchanged inputs; +actual parse, join or publication failure must retain prior durable products and +allow corrected retry. Existing atomic publication and diagnostics are authoritative; +these helpers introduce no new persistence boundary. Reviewed wheel and full +contribution checks are final local gates. Native Qt/browser/platform, hosted +publication and broader API-family proof remain distinct evidence gaps. + +## Temporary middle-button camera navigation (INC-QML-16) + +Upstream disposition: D16, REQ-QML-021 and the INC-QML-16 generic navigation +change are deferred. The following camera contract and helper measurements +belong to the frozen complete fork, not the baseline viewer in this submission. + +Status: **Locally verified at emitted-script and reviewed installed-artifact +boundaries** under REQ-QML-021-AC01–AC03 and changed REQ-QML-019-AC04. Native +browser/device and other-platform behavior remains unverified. +[D16](ARCHITECTURE.md#d16--middle-button-input-owns-only-the-temporary-camera) +assigns mouse middle-button movement to the camera without changing source facts, +node geometry, physics, selection or filters. The Overview control/function/state +and top-ten ranking are removed. Checked full startup, community aggregation, +Select All/None, filters, search and the inspector retain their current roles. + +[frozen html_navigation owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/exporters/html_navigation.py) (`MIDDLE_PAN_SCRIPT`) is a plain JavaScript IIFE using +the existing `container` and `network`, injected once after network construction +and initial physics setup. It has no package, SDK or DOM dependency beyond the +existing viewer. For each active mouse movement, obtain `network.getViewPosition()` +and `network.getScale()`, then call `network.moveTo` with position +`(view.x - dx/scale, view.y - dy/scale)`, unchanged current scale and +`animation: false`. The screen delta is incremental. Reading the scale at each +movement preserves a native wheel zoom that occurs between movements; no wheel +listener or zoom-policy override is added. + +Only mouse middle-button pointer input with a held middle-button mask and a +nonnegative integer pointer identity starts the gesture. Guarded container +pointer capture plus window move/up/cancel listeners keep outside-container +movement and capture-failure fallback bounded. Matching release/cancel, lost +middle-button state, lost capture, window blur or page hide ends the gesture. +Pointer coordinates and view coordinates must be finite, scale positive, and the +derived world position finite; invalid camera/API state safely aborts rather than +accumulating a jump. Cleanup restores cursor/user-selection state before optional +capture release. Middle mousedown/auxclick and active text selection are suppressed +to prevent native autoscroll/selection; left/right/touch and wheel behavior are +otherwise retained. + +The helper writes only temporary camera/input state. It neither calls node-move/ +selection/filter/physics APIs nor writes datasets, RAW metadata, graph JSON, +sidecars, local storage or saved preferences. Camera/capture failure ends or safely +falls back from the gesture; it adds no parser diagnostic, persistence operation +or graph-write bypass. Qt policy 6, AST schema 7 and source fact contracts remain +unchanged. The helper and focused public tests stay below 300 physical lines; +HTML integration measures 789 lines within its existing 810-line legacy ceiling. +The helper/initial-view/middle-pan modules measure 116/245/223 physical lines; +each remains below 300. The inspector-harness compatibility adjustment retains +its own documented 1377-line legacy test ceiling. + +Production emitted-script tests pass source/aggregate pan math at normal and +changed zoom, autoscroll prevention, release/invalid-state paths, input/wheel +coexistence, retained controls and immutable payloads. The 60-case focused suite +includes 24 independent horizontal/vertical/diagonal, scale and view variants. +Existing Overview expectations are superseded, not evidence for changed AC04. +Reviewed source/wheel/isolated-install equality covers all 156 Python payloads; +installed viewer scripts retain canonical/RAW facts, checked startup, source +inspectors and clean release/blur/retry camera behavior. Current exact outcomes +belong to traceability/validation. No native browser visual/device, new hosted or +other-platform result is claimed. + +## Declaration identity and provider lifetime (INC-QML-18) + +`CppDeclarationIdentity` is an immutable per-accepted-file syntax index. Source +path, original declaration span and enclosing lexical scopes own a portable +SHA256 identity. The index accepts bounded local/parameter/auto declarations and +accepted `this` owners; duplicate declarations, writes, deferred/conditional +ownership and computed/member/factory expressions retain rejection reasons. +Display names never replace an absent identity. + +The access collector carries receiver/assigned/engine/provider identities and +provider lexical end offsets. Loads and handles use source-owner plus declaration +identity keys. Context exposure requires one load of that engine/component and +a provider whose lexical lifetime contains the load. Event collectors transport +both native endpoint declaration IDs; the cross-language adapter selects the +correct endpoint before resolving its QML handle. Mutable loads/handles remain +owned by one analysis-run access index; source facts belong to their producer. + +Original spans, graph IDs and graph persistence ownership remain unchanged. +Policy 8 refreshes prior Qt overlays; AST schema 7 stays compatible. Parse/join/ +write failure retention follows existing publication owners. Native execution, +factory/member identities and runtime object lifetime are outside this profile. +The continuing construction review rejects unproven/widget QObject ancestry +without changing native member visibility. Both seams are source-authority checks +under D17 and the existing acceptance IDs. + +## Lexical native types and construction ancestry (INC-QML-19) + +`NativeTypeScope` owns an immutable accepted-file type index. Local, class and +namespace scopes, declaration order, simple using/typedef chains and namespace +aliases resolve literal class spellings against accepted complete definitions. +Unknown, duplicate, conditional, cyclic, unsupported or later declarations block +outer lookup instead of lending a global class identity. Parameters use their +declaration position; local variables and member pointers use their owning source +positions. Emission, connect/disconnect and registrations share this authority. + +`type_alias` and literal `type_include` facts preserve original source ownership. +`IncludedAliasShadows` walks only accepted explicit header paths, with a 128-header +bound; it validates provenance and blocks outer fallback for unavailable imported +aliases. It reads no new includes and does not execute preprocessing. Positive +cross-file alias target resolution, using-namespace directives, templates and SDK +type discovery remain excluded. Cold/full and fresh-header borrowed-context paths +publish the same source-owned facts without mutating borrowed nodes. + +The native class producer adds canonical base names/status using the same lexical +type index. Construction lookup requires complete accepted source/class identity, +matching original spans and proven non-widget QObject ancestry; unknown external +bases, widget subclasses, local QObject shadows or corrupted proof cannot supply +parenting. Native member visibility remains an independent contract. The supported +findChild QObject* filter also requires unshadowed SDK type identity. + +Policy 9 refreshes old derived overlays; AST schema 7 is unchanged. Alias facts +are additive and graph persistence remains owned by existing manual/watch writers. +The class, alias, variable, event and registration collectors each remain under +300 physical lines. No compiler, Qt SDK or corpus execution is introduced. + +## Literal loaders and component engine ownership (INC-QML-20) + +`qt_cpp_loaders` owns bounded literal URL and direct-constructor admission. +The access collector passes source-position type/declaration indexes, then +publishes original-byte qml_load facts. Only exact unshadowed supported SDK +types authorize special loading/root semantics. Parent-only engine/component +constructors are not loads. A component's engine-only constructor records an +association keyed by component declaration identity; later loadUrl uses that +exact engine for root/context-provider joins. Assignment, conditional creation, +factory results or unknown engine identity supply no guessed binding. + +Supported URL/mode/creation overloads are checked independently of literal text. +QUrl wrappers need accepted SDK identity, including rejection of same-spelled +callables. Absolute literal fromLocalFile input becomes a file URI; resource, +relative, computed or nested forms remain unavailable. QString colon-resource +convenience paths and QUrl validation have different semantics. Explicit global +SDK names can bypass a namespace shadow, but cannot bypass a source-defined +global class. No URL or analyzed engine is evaluated. Qt 6.8 +[engine](https://doc.qt.io/qt-6.8/qqmlapplicationengine.html), +[component](https://doc.qt.io/qt-6.8/qqmlcomponent.html) and +[fromLocalFile](https://doc.qt.io/qt-6.8/qurl.html#fromLocalFile) contracts define +the bounded profile; they do not establish general overload/runtime equivalence. + +The existing access index resolves accepted resources/modules, and the resolver +requires one supported source loader before admitting a created/root handle. +Producer facts remain file-owned, association/type indexes unit-owned, and +manual/watch persistence caller-owned. Policy 10 refreshes derived Qt overlays; +AST schema 7 is unchanged. loadData/setData, staged creation, arbitrary SDK +subclasses and dynamic contexts remain outside this increment. + +The acceptance matrix covers literal provenance, provider identity, false SDK +authority, real source/resource/member edits and removal, full/cold/warm/manual/ +watch parity, persisted/query/affected orientation, parse/replace failure retention +and repaired retry/idempotency. Existing diagnostic expectations now assert the +precise declaration-identity reason; no target/retention assertion is weakened. + +| Legacy touched owner/file | Current size / permitted ceiling | Rationale and exit | +| --- | --- | --- | +| Language-fixture maintainer: `tests/test_languages.py` | 5117 / 5120 physical lines | One QML facade/original-span case follows CONTRIBUTING's language admission convention. Existing multi-language ownership is preserved. Extract per-language fixtures through a separate characterized change that keeps discovery and unrelated-language contracts. | + +Every new handwritten loader/probe/test module remains below 300 physical lines. +At the INC-QML-20 checkpoint the unchanged atomic writer's read-only Windows +failure remained INC-QML-23. Later individual/cohort corrections below supersede +that disposition; forced replacement-failure tests remain narrower evidence. + +## Follow-up correction acceptance matrix (INC-QML-21–27) + +| Increment | Success and boundary/rejection | Failure and retained state | +| --- | --- | --- | +| INC-QML-21 | Exact global/namespaced/nested owners and header/source members survive raw-call remapping and export; missing, corrupted, later, duplicate, CV/ref-mismatched or over-limit using authority cannot bind by basename | Actual manual/watch edits and original-byte proof; common publication failure/retry contracts apply | +| INC-QML-22 | Unshadowed static reflection and handle forms target receiver-owned members; alias/class/forward/conditional SDK-name shadows and invalid receiver/lifetime evidence create no target | Original spans, query/affected and strict whole-graph manual/watch parity; parse/write failures preserve all four accepted products | +| INC-QML-23 | Writable destinations and bounded OS fallbacks preserve the individual writer contract; read-only and directory destinations reject before displacement | Landing/source-cleanup/backup-cleanup faults restore prior bytes; a second restore fault retains explicit recovery evidence | +| INC-QML-24 | Literal SDK loaders retain source provenance and global qualification; incomplete SDK-name source declarations cannot authorize engines, wrappers or roots; conditional/reassigned identity remains uncertain | SDK→forward→SDK→removed manual/watch parity, parse/publication retention, repaired retry and repeat | +| INC-QML-25 | Prepared graph/root/manifest/Qt-state and optional clustered products form one accepted cohort; unchanged bytes skip replacement; duplicate targets and detected concurrent change reject | Actual read-only products, staging/late replace faults and ordinary/non-OS restoration failures retain or explicitly report recoverable disk copies; first-build failure accepts no products | +| INC-QML-26 | Only absent fresh, unreferenced source-less AST placeholders are pruned after a complete successful Qt refresh; live edges/hyperedges, semantic/native/source-owned facts survive | Partial/failed refresh grants no prune authority; common publication failure/retry contract protects the resulting graph | +| INC-QML-27 | Accepted property/provider NOTIFY authority creates real canonical subscriptions for direct/Connections handlers; explicit formals and legacy injected parameters have separate scope | Invalid/CONSTANT/unavailable/ambiguous notify evidence creates no edge; real source/metadata/QML edits and guarded publication prove removal/recovery | + +Successful source resolution has no new operational warning. Static rejection +retains source status/reason; actual parser/join/write failures use their owning +diagnostic contract. Native execution and process/power-loss simulation are not +performed because the runtime analyzes source and promises exception recovery only. +Other-platform/native system evidence remains explicitly unexecuted. + +### Producer and API authority + +`CppIdentity`, class proof and member canonicalization own qualified C++ metadata +before deduplication. Exact UTF-8 spelling is transported separately from bounded +display text. Unique source body/signature and accepted containment evidence are +required; over-limit namespace authority is rejected rather than truncated into +a resolved target. Full source/AST/member direction remains consistent. + +`NativeTypeScope.sdk_type` distinguishes external SDK spelling from any visible +source declaration. `CppDeclarationIdentity.type_binding` retains declared type +spelling/position independently of runtime identity. Reassignment can preserve an +observed SDK loader fact while preventing its target join. Explicit global syntax +bypasses a namespace shadow, not a source-defined global SDK-name class. Runtime +factory/member expressions and unsupported aliases remain unavailable. Accepted +literal includes carry generic class declarations as SDK shadows separately from +opaque alias targets. Included forwards, transitive paths, source order and the +128-header walk bound grant no new corpus discovery or native provider admission. + +`QtMemberViews.property_notify` follows the accepted property's real NOTIFY +accessor, requiring its provider, signal role and source proof. It does not invent +a native propertyChanged declaration. QML handler facts carry `implicit_parameters`: +legacy blocks alone receive injected names; function/arrow formals own bindings. +Final AST schema 9 and Qt policy 12 invalidate stale unchanged-input facts. IDs, +original spans and graph projection remain governed by the existing contracts. + +### Publication and cleanup + +The watch/update owner uses `ProductPublication` to snapshot old products, prepare +candidate serializer output and commit replacements. Source cache ownership is +independent. The Qt state writer receives a stage directory; its candidate is +accepted only with the cohort. Successful cleanup failure warns without reporting +the coherent accepted update as failed. Unsuccessful rollback retains snapshots. + +Partial-setup cleanup regression coverage injects failure through `Path.unlink`, +the actual production boundary, and delegates real removal for unrelated owned +copies. Python 3.10 caches the underlying `os.unlink` accessor, so patching only +the later module function does not exercise that cleanup failure. INC-QML-48 +corrects the test seam and records the rejected owned target while retaining +diagnostic redaction, unchanged accepted products and durable recovery assertions. +The transaction implementation and public failure contract remain unchanged. + +After complete Qt extraction/reconciliation, `prune_stale_ast_orphans` receives +fresh identities and reference ownership. Partial/failed extraction cannot prune. +It preserves source-backed, native/semantic, callable and live edge/hyperedge facts, +and never mutates borrowed dictionary inputs. Non-Qt legacy reconciliation remains +under its existing owner. + +| Touched legacy owner/file | Measured size / ceiling | Cohesion rationale and extraction exit | +| --- | --- | --- | +| Generic extractor maintainer: `extractors/engine.py` | 7720 / 7730 physical lines | Narrow producer identity integration within the existing dispatcher; extract C++ ownership separately after characterization under the migration playbook. | +| Generic resolver maintainer: `extractors/resolution.py` | 3981 / 4000 | One guarded class/member canonicalization seam; move that responsibility in a separate characterized increment. | +| Shared writer maintainer: `paths.py` | 577 / 600 | Preserve one OS replace/rollback owner; separate a cohesive writer extraction after failure/ordering characterization. | +| Update maintainer: `watch.py` | 2487 / 2550 | Cohort integration replaces existing duplicated writes and shrinks the file; separate publication orchestration only after compatibility characterization. | +| Extractor facade maintainer: `extract.py` | 9009 / 9010 physical lines | One existing canonicalization call supplies raw calls to the same resolver owner; preserve dispatcher direction and extract a cohesive facade responsibility separately under the migration playbook. | +| Cache maintainer: `cache.py` | 1782 / 1783 | Existing schema seam only; retain the previously documented cache extraction boundary. | + +New helper and test files remain under 300 physical lines. Existing extractor +facade ownership and dependency direction remain unchanged; children do not import +`graphify.extract`. Plan review distinguishes corrected cases from open wider +adoption, inherited-signal and overload support. + +## Explicit emission source authority (INC-QML-28) + +The shared call scanner keeps its default name inventory unchanged. Its explicit +opt-in admits only source annotations proved against standalone executable AST +call ranges and offset-preserving masked bytes. A preceding local define/undef +blocks annotation authority without preprocessing. Observed explicit sites remain +owned by their actual callable/file even when no signal declaration is available; +the resolver alone supplies accepted canonical targets. + +Event dispatch honors explicit emission before connect/disconnect spelling. It +does not recover rejected annotation authority from a nearby text prefix. A +computed receiver's terminal call keeps the full outer AST range and unavailable +receiver identity; an inner factory sharing its start cannot become an unknown +bare signal. Known bare signal calls retain their separate inventory contract. +This changes derived event admission only: Qt policy 15, AST schema 10 unchanged. +Existing publication and diagnostic owners retain failure/retry ordering. + +## Project and header adoption (INC-QML-08a) + +`extractors/qml_qmake_values` owns original-position lexical statement coverage and +literal paths; `qml_qmake` owns resulting module/source facts. Exact `$$PWD` +prefixes use the parsed file's directory. Only `=` and `+=` are admitted. Paths +are bounded to 384 UTF-8 bytes and scopes to 64 levels. Unrelated toolchain +settings do not reject module facts; relevant conditions, unknown evaluation, +PWD reassignment, expansion and root escapes do. `qt_qmake_assignment` is observed +lexical evidence; `qt_qmake_statement` records ignored/unresolved coverage. +Neither expands corpus discovery or supplies independent lookup authority. +QML_IMPORT_PATH tooling hints and QMLPATHS build hints remain distinct from +explicit analysis roots. + +`cpp_header` owns the existing bounded 256 KiB classification prefix. It masks +comments, continued comments, normal/raw strings and characters before testing +visible C++ markers. The facade retains discovery and Objective-C priority. +Whitespace, BOM, CRLF and Unicode retain their original-byte meaning; plain C and +inconclusive headers retain C dispatch. Classification requires no optional Qt +parser or project execution. AST schema 11 and Qt policy 16 invalidate old source +and derived facts at unchanged package versions. Root boundaries and persistence +ordering are unchanged; normal retry reparses corrected metadata/source. + +## Declared provider and child-service APIs (INC-QML-08b) + +`qt_cpp_api_shape` preserves CV, indirection and member operators. +`qt_cpp_api_types` records declaration-position targets for returns, public fields +and properties. `CppDeclarationIdentity.declared_type_binding` retains root +spelling without changing older normalized callers. The access collector carries +exact root identity and at most eight literal call/field steps. One object pointer +or lvalue reference supplies an API hop; root values/references require dot access, +pointers arrow access. Direct address-of and implicit this retain source proof. +Unsupported shapes, cycles, conditions, duplicate members or missing canonical +callables grant no target. Helpers do not reread source, evaluate getters or own +persistence. + +`DeclaredProviderIndex` validates borrowed canonical class/member/type evidence. +`qt_context_paths` follows accepted child-property/accessor chains and independently +checks serialized endpoints. Member facts require the actual generic callable +role and original class/member span; property READ accessors require exact owner, +file, annotation span/name and zero arity. NOTIFY consumers repeat the producer's +real property/signal/accessor proof. Canonical target existence alone supplies no +authority. Type evidence stays distinct from bounded display values. + +`qt_context_subscriptions` projects source-owned Connections and literal +signal.connect callbacks after provider resolution. A `context_handler_endpoint` +separates inverse contains/reference pairs. Native implicit handler binders precede +nested callback/provider lookup; explicit function/arrow formals and nearer local +JavaScript declarations retain their lexical authority. Notifications follow the +real NOTIFY signal. Cold/warm and manual/watch production updates rederive the +complete overlay after relevant C++, QML or metadata changes. D21 records the +static API/runtime identity distinction; final epochs and installed proof belong +to validation. + +`qt_qml_callback_proof` independently validates the original call, callback, +component/object scope, owner and source spans. Connections endpoints must be the +actual source handler. Literal signal.connect callbacks use the existing QML +index; captured local JavaScript binders retain precedence. A sibling function's +existing ID cannot replace that source correspondence after serialization. + +## Logical dependency direction (INC-QML-30) + +`graph_direction.logical_endpoints` validates the complete stored endpoint pair. +Both absent markers retain legacy orientation; one absent, foreign, wrong-type or +contradictory directed marker rejects the edge. Complete undirected markers may +reverse physical insertion order. Exact endpoint types prevent boolean/integer +identity coercion. The validator reads metadata without changing the graph. + +`affected.affected_nodes` builds one accepted incoming index for undirected +graphs; directed graphs retain native adjacency. The same validator governs +outward class/member seeding and `qt_affected.owned_ancestors` source promotion. +Depth, relation filters and source-site provenance keep their existing contracts. +`paths.load_node_link_graph` restores typed direction only when both markers are +absent; explicit corrupt transport remains visible for consumer rejection. +This consumer correction changes neither source facts nor cache epochs. Existing +publication owners and failure/recovery ordering remain unchanged. + +## JSON direction rejection (INC-QML-31) + +`export.to_json` validates every physical/logical pair before consuming private +markers or writing. Native directed graphs require the same directed pair; +undirected graphs permit its complete accepted reversal. Partial, foreign or +ill-typed markers return failure with QT_EXPORT_DIRECTION before the destination +is touched, including under force. Serialization leaves the input graph unchanged. + +`qt_export.logical_endpoints` delegates to the shared pair validator and preserves +its existing bounded ValueError contract. External database/Cypher exports retain +the established complete logical-marker interpretation for bidirectional storage +wrappers; they still reject partial, foreign and ill-typed pairs before connection +or file output. That external interpretation does not authorize contradictory +native-directed JSON. Existing coordinated publication stages restore the prior +product cohort on serializer rejection; repaired retry and repeated publication +use the normal path. No source facts, policy epoch or writer ownership changes. + +## Initial raw-update comparison (INC-QML-32) + +`watch._canonical_graph_for_compare` retains comparison ownership. It normalizes +only missing-versus-empty diagnostics, failed-source, QML/Qt-failure and hyperedge +lists. Nonempty lists and wrong-type values still differ. The extraction-run +`extracted_sources` list is transient comparison provenance; authoritative source +facts remain compared in their nodes/edges. Unknown metadata remains observable. +The function copies its input and owns no mutable run/cache/persistence state. + +The first accepted raw graph survives an unchanged second manual/watch update +without a byte or mtime rewrite. Genuine source edits and existing publication +failure/recovery still use the owning writer and cohort stages. Clustered topology +comparison is a separate contract. This correction adds no schema/policy epoch. + +## Shared generic C++ file-role proof (INC-QML-33) + +Previously, membership and source containment each required an empty generic +file metadata record. The constructor producer now retains bounded include/shadow +proof on that canonical node. `qt_source_file_role.generic_file_role` owns the +shared predicate: exact AST/fresh origin, source label/location and non-callable +file role, followed by the exact accepted constructor transport. Each caller +still owns normalized source paths and unique endpoint selection. + +`cpp_constructor_type_authority.valid_source_transport` extracts the existing +immutable transport checks after their characterization. Inputs are accepted +dictionaries; output is a boolean. The predicate reads no source, stores no state, +and owns no cache or persistence. Constructor authority still requires a complete +record separately. An incomplete valid file can contain source sites without +becoming a constructor or SDK type proof. Consumers import this focused validator; +extractor children do not import the extractor facade. File IDs, fact ownership, +join ordering, graph writers and cache epochs remain unchanged. Foreign, +duplicate, semantic or malformed nodes cannot borrow file identity. + +## Candidate direction preflight (INC-QML-34) + +`graph_direction.valid_direction_pairs` consumes explicit edge tuples and their +native directed flag through the established logical-pair validator. Watch owns +the bounded failure diagnostic and invokes it before raw comparison/publication +and before clustered topology comparison. Neither an equal topology nor force +can authorize a partial, foreign, ill-typed or contradictory candidate. The +validator does not mutate candidates, publish data or accept analysis checkpoints. +Existing cohort/cache retention and repaired retry remain with their original +owners. External database wrapper interpretation remains separately documented. + +## Exact prior import ownership and external cleanup (INC-QML-35) + +The reconciliation caller captures `prior_import_ids` from the original persisted +graph before any origin inference. `qt_orphan_cleanup.previous_import_targets` +uses the builder's supplied canonical import family, exact source-owned AST +import/context/confidence, valid direction and unique owner/target identity. +An inferred or backfilled AST origin cannot grant historical ownership. + +The pure cleanup owner retires only accepted prior import endpoints matching the +generated external shape, absent from fresh output and every surviving ordinary +or hyperedge reference, during a complete successful Qt refresh. Missing/semantic +origin is the legacy external shape; foreign/AST origin, source/location, +metadata presence, callable roles, wrong type/label and unrelated IDs block +retirement. View attributes do not invent source ownership. Borrowed inputs, +source-backed facts and still-referenced nodes remain unchanged. The caller owns +the per-run proof set, extraction, reconciliation and publication; the helper +reads no source, executes nothing and owns no cache or persistent state. + +## Relationship mechanism authority (INC-QML-36) + +The Qt/QML projection repeats original occurrence ownership, file/location/span, +status, lexical admission and native endpoint roles. It accepts the exact +operation-to-relation/context mapping, including reads, calls, aliases, signal +emissions, subscriptions, loads and reverse reflection. Complete endpoint IDs +cannot repair an altered operation, flow, endpoint role or borrowed occurrence. +The builder selects existing typed bridge producers independently of their +relation/context/confidence and validates before its generic relation branch; +`uses` cannot bypass proof. Context-binding ownership/exposure remains with its +separate contract. Generic language relationships remain unchanged. + +## Per-occurrence event annotation (INC-QML-37) + +`resolve_qml_event_access` retains fresh-result mutation ownership. Each accepted +site creates a local node/edge collection before adding it to the run's aggregate. +`update_qt` rebuilds bounded literal transport without recursively transporting +`raw_values`. Canonical source-owned role nodes and same-occurrence partial native +edges receive the final bridge annotation, so deduplication cannot preserve the +older incomplete context. IDs, original spans and independent occurrences survive. +The event resolver reads accepted facts only; source/graph/cache publication +owners and runtime exclusions are unchanged. Qt policy 18 triggers same-package +derived refresh. These corrections implement the existing source-authority and +publication decisions D17/D19; no competing ownership or runtime is introduced. + +## First raw external-stub publication (INC-QML-38) + +The canonical builder mint remains append-only. The updater records the node-list +length immediately before minting and stamps only its newly appended placeholders +with the established semantic origin. Existing/source-backed/AST/manual/semantic +nodes retain their own fields and origin. Subsequent legacy provenance inference +remains compatible, and comparison continues to observe origins rather than +discarding them. Writer/cohort/checkpoint ordering remains unchanged. This is +publication-created placeholder state, with no source parser/cache schema change. + +## Canonical input and diagnostic boundaries (INC-QML-40/41) + +The reproduced facade gap is owned by the existing cached absolute-source-file +normalization in `extract.py`. The correction resolves each supplied +physical identity before its contained/external decision and reuses that value +for endpoint-key forms. Source content, original byte spans, literal fact values, +external-file policy and native traversal validation retain their owners. +This is a shared normalization correction, so generic/Python and true external +controls accompany Qt/QML success and lifecycle regressions. No separate path +registry or new mutable root is introduced. Per-file caches contain facts before +facade normalization, so warm replay can repair provenance without changing AST +schema 12. Qt policy 19 is the approved same-package refresh for prior policy-18 +native/Qt products, including no-change updates. Existing stamp inspection, +cohort publication, retention and repair ordering remain authoritative. +Generic-only older API outputs require an explicit forced rebuild for repair. + +The current facade measures 8,997 physical lines, from 8,993 before INC-QML-40; +the earlier tables record their historical 9,009-line snapshot. The existing +9,010-line ceiling, extractor-facade maintainer ownership and separate characterized +facade extraction exit remain in force. This correction adds four net lines to +the existing cached responsibility and does not mix a mechanical extractor port +or unrelated split into the behavior fix. + +The corrected diagnostic escape is owned by `qt_qml_pipeline`'s existing join +failure handler. `_diagnostic_source` guards physical identity lookup and falls +back to existing lexical `source_path` containment. It retains the complete safe +relative label up to 160 characters; outside/unavailable, overlong, C0/C1/DEL and +Unicode line/paragraph-separator labels become the empty `source_file` string, +without truncation. This diagnostic-only fallback annotates every affected source with the existing failure +code and does not lend lookup/endpoint authority. CLI/watch still reject before +normal graph-product publication; prior products and recovery retain existing +cohort ownership. OSError/RuntimeError probes distinguish complete diagnostics +from the already passing retention/recovery controls. INC-QML-40/41 have local +source and fresh installed proof; current hosted acceptance remains pending. + +## Walked source identity (INC-QML-42) + +The facade owns per-run admission, `_walked_rel`, realpath memoization and remap +publication. The 31-line `source_identity` helper owns relative-name selection +only. Inputs are absolute source/root paths, the physical source and an explicit +cached-realpath callback/CWD; output is the accepted root-relative walked name +or a containment ValueError. It stores no state, reads no content and imports +no extractor facade. Physical containment precedes written root-relative names, +then a lexical ancestor identifying the scan root, then the physical fallback. +Unavailable parent anchors are skipped without exposing their backend body. + +Primary file, symbol-prefix, stem/target decomposition and final source_file/ +definition_file normalization share that decision. Root-relative keys preserve +two discovered source owners of the same target, including cache replay and +aliased scan roots, without lending external/native authority. Existing producer +IDs and topology, original bytes/spans, syntax-cache fragments and graph-product +cohort ordering retain ownership. The facade measures 8,998 physical lines, +within its existing 9,010 ceiling; the focused regression file is 277 lines. +This is a characterized normalization correction, not an extractor migration. + +Policy 20 triggers no-edit native/Qt refresh of older policy-19 products with +schema 12 unchanged. Failure retains the complete prior cohort and corrected +retry/repeat completes; generic-only old products require full/forced rebuilding. +The separately reproduced co-owner watch-invalidation and NTFS short-leaf +spelling boundaries are INC-QML-44/45; this checkpoint does not claim them fixed. + +## Physical co-owner watch invalidation (INC-QML-44) + +The watch selector owns detection, exclusions, semantic-document separation, +deletion handling and the existing publication cohort. Its new stateless +`watch_coowners` boundary receives positive changed paths and already accepted +nonsemantic inputs. Within one call, it groups verified contained regular files +by nonzero device/inode identity; a canonical contained path is the conservative +fallback where usable inode IDs are absent. Hardlinks join only with actual +inode evidence. No content is read, source discovered or durable cache introduced. +The selector keeps notification order and appends additional owners in corpus +order; order cannot determine source-ID ownership or hide a remap collision. + +Unexpected resolution/stat failure, disappearance after admission, foreign or +nonregular identity rejects with `WATCH_SOURCE_IDENTITY_FAILED`. Its existing +watch exception boundary reports failure and retains graph, manifest, analysis +root and Qt stamp. Safe quoted root-relative context is bounded to 160 characters; +controls, line separators, unavailable and longer context become an empty field. +Backend exception bodies are omitted. Ordinary absence before discovery keeps +existing deletion behavior. Restore the source and retry through normal detection; +accepted repeat updates preserve product bytes. The error catalog owns the code, +and traceability distinguishes source, installed and hosted evidence. + +## Native file-spelling admission and physical ID ownership (INC-QML-45) + +The facade normalizes initial input paths before parser checks, worker dispatch +or cache access. `source_identity.normalize_input_source` leaves POSIX and missing/ +non-file inputs to existing handling. Existing Windows regular files use bounded +`GetLongPathNameW` output and unchanged physical-target/same-file checks. The +returned long spelling preserves lexical junction/symlink owners; resolving the +entire input would erase those owners. Successful output uses UTF-16 code-unit +bounds, including supplementary Unicode characters. The helper reads identity +metadata only and retains no state, cache, root or content ownership. + +Unavailable/malformed/oversized output or changed physical identity rejects with +`SOURCE_INPUT_IDENTITY_FAILED` before worker/cache work. Direct extraction names +the failed stage and repair/retry action without claiming a durable graph exists. +The existing manual/watch owners retain prior products on that rejection. Safe +lexical relative context is JSON-quoted and bounded; unavailable, unsafe or long +context is empty. Missing/non-file inputs preserve their previous downstream +result rather than becoming a new whole-batch identity failure. + +`resolved_source_owners` collects explicit walked claims for each physical form +within one analysis run. Primary walked IDs remain separate. A supplied canonical +physical owner wins its shared absolute form; one unique alias keeps the existing +fallback. Several aliases without that physical owner retain the portable +physical endpoint form, with no arbitrary alias choice or newly admitted source +authority. Ordinary unresolved import stubs retain the graph builder's existing +policy; a stub cannot establish a parsed declaration or native endpoint role. +The same ownership applies to suffixed callable entries. Prefix/stem +lookups retain each walked owner. This corrects watch rename order dependence +without sorting the notification list or overwriting another owner's file ID. + +Combined Qt policy 21 refreshes older derived products; AST schema 12 and cache/ +publication formats remain unchanged. Generic-only historical outputs use an +explicit full/forced rebuild. Acceptance records distinguish the earlier +policy20 checkpoint from the combined source, installed and hosted results. + +## Manual directory-link update profile (INC-QML-46) + +The update parser accepts `--follow-symlinks` in either position around its one +root argument and forwards the explicit Boolean to `_rebuild_code`. Omission +retains false. The existing rebuild/discovery owner applies ignore rules and +physical containment, selects inputs and publishes the graph/state cohort. A +saved graph or scan-root marker does not persist this traversal permission. +Unknown flags and multiple roots reject with the existing option diagnostics +and exit 2 before rebuild or publication. Accepted failures retain their existing +writer diagnostic, product/cache retention and retry policy; no new error code +or publication boundary is introduced. [D25](ARCHITECTURE.md#d25--manual-directory-link-discovery-is-an-explicit-option) +records the scope decision. + +Policy-upgrade fixtures use the explicit profile for older-state setup, manual/ +watch updates, failed staging, retry, full comparison and repeat. A separate real +POSIX control retains default exclusion. Native short-parent fixture expectations +under INC-QML-47 require exact long entry spelling after input admission, while +the distinct symlink fixture still requires its exact lexical parser owner. +Neither change relaxes graph, native endpoint, cache or persisted provenance +assertions. Source, installed and hosted outcomes remain revision-bound. + +## Current-upstream integration — INC-QML-49 / D26 + +The integration maintainer owns reconciliation of upstream 35adf43/package +0.9.76 with the preserved Qt/QML revision 65103f8. Source discovery combines +exact Qt metadata admission with upstream installed-file exclusion. Cache schema +13 preserves both producer contracts; Qt policy 22, per-file facts and semantic +cache lifetimes retain their existing owners. CLI qualified endpoint lookup +feeds the existing direction-aware path consumer. JSON's original-links input +preserves upstream parallel relationships and then validates every Qt logical +endpoint before the existing atomic writer can publish. + +Rejection and failure policy remain fail-closed: source boundaries cannot expand, +invalid direction cannot publish, and a stale cache does not become a successful +empty extraction. Current/previous graph and cache retention follow the owning +contracts. No custom merger, corpus execution, second parser architecture or +new global mutable state is added. Joint regressions and exact integrated runner +proof belong to tests/TRACEABILITY.md; previous-base green checks do not verify +this revision. Existing QML PR #1748 is a publication overlap, not integrated code. + +The integrated profile's focused legacy cohesion exceptions preserve existing +owners and upstream migration sequencing: + +| Owner / touched file | Measured integration size / ceiling | Rationale and extraction exit | +| --- | --- | --- | +| Extractor facade maintainer: `graphify/extract.py` | 9226 / 9250 physical lines | Combine discovery gates in place; extract one characterized facade responsibility separately under the migration playbook | +| Cache maintainer: `graphify/cache.py` | 1826 / 1840 | One epoch seam; stat-index rebinding remains upstream-owned. Separate a cohesive cache extraction after characterization | +| CLI maintainer: `graphify/cli.py` | 5079 / 5090 | Preserve upstream dispatch and exact endpoint selection; separate characterized path-command ownership before further growth | +| Export maintainer: `graphify/export.py` | 1402 / 1410 | Original parallel links feed existing direction validation and writer; separate a cohesive serializer only after preservation/failure characterization | +| CI maintainer: `.github/workflows/ci.yml` | 485 / 510 | Extend existing native/source case selection and proof in the same workflow; extract shared evidence collection if another workflow requires it or further growth exceeds this ceiling | +| Upstream fixture maintainer: `tests/test_extract.py` | 5453 / 5460 | Keep the existing isolated subprocess and extraction assertions; separate incremental-context characterization before a test-module extraction | + +These ceilings apply to the measured integration, not routine growth. Incoming +oversized upstream files retain their upstream responsibilities; no unrelated +mechanical extraction is mixed into this reconciliation. New helpers and joint +regression files remain below 300 lines. + +## Followed Qt metadata provenance — INC-QML-50 / D27 + +The stateless `extractors/qml_source_identity.py` owns admitted lexical source +selection for Qt/QML producers. It receives the path and root explicitly and +supplies a local real-path callback to the existing walked-name boundary. +Physical normalization/containment and Windows long +entry spelling use the existing source-identity boundary; a real separately +walked contained alias then retains its lexical source name. FactBuilder, qmldir +and literal project/resource/type-description readers use that same choice. +Source ranges remain offsets into the original bytes; resolver lookup values +and native endpoint authority keep their established owners. + +Qt source/metadata bypass syntax caching. Qt policy 22 reconstructs unchanged-input +derived products under the current discovery profile before existing atomic +publication; AST schema 13 and content-owned semantic cache are independent. +Default non-following, foreign/ignored/nonregular rejection and guarded context +diagnostics remain unchanged. Source/read failure and graph replacement failure +retain prior products, with normal retry/repeat recovery. Native junction/POSIX +fixtures and direct/full/warm/manual/watch evidence are individually assigned in +traceability. The recorded native/POSIX source and installed profiles pass at +the [integrated checkpoint](../../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2). + +Lexical normalization must not precede physical resolution of an accepted POSIX +link/`..` input: those spellings can denote a different file from a lexical +`abspath` collapse. Establish the original admitted target first; uncertain +walked spelling falls back conservatively to that contained target. Existing +native admission failures retain SOURCE_INPUT_IDENTITY_FAILED through typed +QML/metadata results with safe context and retry guidance, rather than becoming +QML_ROOT or a successful empty result. No native backend body or target authority +is copied into public facts. diff --git a/docs/qt-qml/DEVELOPMENT.md b/docs/qt-qml/DEVELOPMENT.md new file mode 100644 index 0000000000..6db1d46a08 --- /dev/null +++ b/docs/qt-qml/DEVELOPMENT.md @@ -0,0 +1,133 @@ +# Development and GitHub workflow + +This foundation uses the repository's Python/uv workflow. Qt/QML are analyzed +inputs; development does not require replacing Graphify with a Qt application. +Read [AGENTS.md](../../AGENTS.md), [CONTRIBUTING.md](../../CONTRIBUTING.md), and the +[increment plan](PLAN.md) before a behavioral change. + +## Account access and remotes + +Use [GitHub CLI](https://cli.github.com/) for authenticated REST/GraphQL operations +and Git for version control. Authentication is stored in the operating system +credential store. Do not print tokens or commit credential configuration. + +```text +gh auth login --hostname github.com --git-protocol https --web --scopes repo,workflow,read:org +gh auth setup-git --hostname github.com +gh api user --jq .login +gh api repos//graphify --jq .permissions +git remote -v +``` + +The OAuth `repo` and `workflow` scopes support repository development and workflow +operations within the signed-in account's permissions. They do not bypass +organization policies or grant administration of upstream Graphify. Verify +operation-specific permissions and the repository before any mutation. See the +[GitHub authentication documentation](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api). + +Use `origin` for the contributor fork and `upstream` for +`https://github.com/Graphify-Labs/graphify.git`. The local `remote.pushDefault` +points at `origin`. Keep the current approved workspace and use explicit branch +names when pushing. New shell sessions must see `gh` on PATH; a running application +may need a new terminal session to pick up a newly installed CLI. + +The upstream development branch is `v8`, not `main`, at this baseline. +Verify its current status before each increment: + +```text +git status --short --branch +git fetch upstream +git log -1 upstream/v8 +git log -1 +git switch -c codex/ +``` + +Preserve existing collaborator changes and do not switch branches over unfinished +work. The foundation branch is `codex/qt-qml-foundation`; subsequent increments +start from an agreed base containing the required predecessor changes. +Record its exact revision and the reviewed upstream SHA. Preserve the foundation +instructions/documents when creating the first execution branch; branching only +from upstream would omit the local foundation. Keep foundation policy changes +distinct from parser or feature changes when preparing review diffs. + +## Environment and baseline checks + +```text +uv sync --frozen +uv run --frozen python -X utf8 -m pytest tests/test_detect.py tests/test_languages.py tests/test_extract.py tests/test_build.py tests/test_cache.py tests/test_watch.py -q --tb=short +uv run --frozen ruff check . +uv run --frozen pyright +uv run --frozen python -m tools.skillgen --check +``` + +The module-based pytest runner gives Windows worker processes an importable main +module; `-X utf8` avoids host-default text-encoding differences. The original +POSIX upstream CI command can remain valid in its own environment. Do not silently +discard Windows failures or modify assertions merely to make the baseline green. + +`uv sync --frozen` installs the locked core and development dependencies. Optional +language/provider tests can skip. A full CI reproduction uses +`uv sync --all-extras --frozen` and the upstream Python-version matrix on suitable +hosts. Record the environment and extras with every result. See +[VALIDATION.md](VALIDATION.md) for the narrower Windows baseline actually run. + +Use an existing Python 3.10+ interpreter explicitly with `uv sync --python ` +if the machine's default Python launcher or managed installation is unavailable. +The repository-local `.venv` remains ignored. Do not rewrite `uv.lock` during a +baseline check. Dependency changes require an intentional reviewed lock refresh. + +## Work already proposed upstream + +The feature is tracked by [issue #1716](https://github.com/Graphify-Labs/graphify/issues/1716) +and [PR #1748](https://github.com/Graphify-Labs/graphify/pull/1748). Review their +current state, code, tests, licensing and checks before creating overlapping work. +Use the immutable reviewed head recorded in [AUDIT.md](AUDIT.md) for reproducible +comparison. Coordinate reusable fixes and avoid opening a duplicate feature issue. + +```text +gh api repos/Graphify-Labs/graphify/pulls/1748 +gh api repos/Graphify-Labs/graphify/pulls/1748/files --paginate +gh api repos/Graphify-Labs/graphify/commits//check-runs +``` + +Read-only review is part of INC-QML-00. Do not apply an old PR's replacement function +body blindly onto a newer upstream base: preserve current C++ normalization, +test-macro handling, resolver contracts, and cache/update invariants. + +## Increment implementation and handoff + +For each increment, record requirement IDs, scope, source-fact invariants, tests, +and compatibility impact before coding. Add hand-checked synthetic/public fixtures +and behavior-led tests. Keep parser facts separate from module resolution and +graph projection. Update the canonical documents and traceability in the same PR. +Map every applicable `QML-XXX-ACYY` acceptance ID to an individual test/check and +its result or explicit gap. A requirement is Verified only when all its applicable +criteria have passing evidence; an aggregate test count is insufficient. + +Run targeted tests during development and required full-suite/lint/type/generated +checks before handoff. Source-fragment changes use `tools.skillgen`; generated +assistant files are not hand-edited. After code changes, refresh the local graph +with `uv run --frozen graphify update .` and inspect its outcome. Use the graph for +ordinary repository navigation when available; source evidence is required when +auditing the graph's own correctness. + +Before publishing a branch, inspect the staged and complete diff for private names, +machine paths, credentials, generated outputs, and unrelated changes. Use public +fixtures and retain license notices. The sanitized repository standard contains +only reusable development practices and public project references. + +When publication is in scope, use an explicit push and a narrow PR. Match the +base to the agreed upstream/fork target, and write the description to a file so +newlines and literal shell characters are preserved: + +```text +git push origin HEAD:refs/heads/codex/ +gh pr create --repo /graphify --base v8 --head :codex/ --title "" --body-file +``` + +Verify the resulting PR head and checks through the API. The baseline CI push +filter does not run on `codex/*` pushes; a matching-base PR or an explicit supported +dispatch is needed. A passing local baseline is not a claim of passing GitHub CI. +Attach each newly created PR to the active task and keep its description aligned +with the final implementation. Merge only under the repository's authorized review +and check policy. diff --git a/docs/qt-qml/ERRORS.md b/docs/qt-qml/ERRORS.md new file mode 100644 index 0000000000..09fef76a2a --- /dev/null +++ b/docs/qt-qml/ERRORS.md @@ -0,0 +1,506 @@ +# Qt/QML diagnostics + +## Upstream submission scope + +The [scoped upstream contribution](UPSTREAM_CONTRIBUTION.md) retains bounded Qt/QML analysis +and its source, incremental, publication and consumer contracts. REQ-QML-019 +and REQ-QML-021, D12/D16, and the generic viewer portions of INC-QML-09/13/16 +are deferred for this submission. Native Codex REQ-CORE-001-AC01/AC05 and the +broad fork repository policy are also deferred; the baseline installer and +repository policy apply. Constructor and source-containment work in INC-QML-13 +remains in scope. + +The [complete frozen fork](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4) +and its recorded 8b6c9d2 source/runner evidence retain their own revision scope. +Deferred feature sections and their exact tests below are historical evidence, +not implemented or verified contracts of this narrower contribution. A changed +contribution head requires fresh applicable checks; prior totals, skips and +hosted results do not establish those checks. + +Adapters own source diagnostics; the writer owns publication guards. Paths are +relative/bounded; messages omit source content and dependency exception details. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| QML_PARSER_MISSING | error | Optional parser absent | Install graphifyy[qml] | +| QML_PARSER_LOAD | error | Parser cannot load | Reinstall pinned extra | +| QML_READ | error | Read/UTF-8 failure | Restore readable UTF-8 source | +| QML_SYNTAX | error | Incomplete/malformed grammar | Correct source | +| QML_UNSUPPORTED | error | Unsupported top-level syntax | Use/extend tested profile | +| QML_ANALYSIS_FAILED | error | Unexpected source-analysis/collector failure | Correct analyzer failure and retry; no partial declarations are authoritative | +| QML_LIMIT | error | Input over 5 MB, AST over 100,000 nodes/depth 256, semantic field over 512 encoded bytes | Reduce input or extend bounded profile | +| QML_EMPTY | info | Empty editor file | Add a component | +| QML_GRAPH_PRESERVED | error | Failed/omitted/partial QML | Correct failure and retry; force cannot bypass | +| QML_ROOT_MISMATCH | error | Unsafe scoped-ID subfolder rebase | Update absolute project root | +| QML_ROOT | error | Source is outside explicit root | Use an accepted source within the scan root | +| QML_RESOLUTION_FAILED | error | Project join raises | Correct join failure, retry; graph publication is rejected | +| QML_METADATA | rejection prefix | Invalid literal-transport map, field type, base64/UTF-8 or rejected membership declaration/input identity | Restore readable accepted input and re-extract valid source facts; joins surface the failure as QML_RESOLUTION_FAILED | +| QML-META-001 | error | Malformed/unsupported qmldir directive | Correct directive to supported literal subset | +| QML-META-002 | error | Metadata read/root/size failure | Restore readable in-root metadata | +| QML-RESOLVE-001 | coverage | Unavailable/ambiguous/dynamic lookup site | Supply supported corpus evidence; no guessed edge is emitted | +| QML-EXPRESSION-001 | coverage | Unresolved, ambiguous, dynamic or unsupported read/call/alias/handler site | Inspect site reason and supported scope/import evidence; no guessed edge | +| QML_SCRIPT_READ | error | Accepted imported script is unreadable or invalid UTF-8 | Restore readable UTF-8 script and retry | +| QML_SCRIPT_PARSER | error | JavaScript parser cannot load for imported-script overlay | Restore the installed JavaScript parser dependency | +| QML_SCRIPT_SYNTAX | error | Imported-script AST has syntax errors | Correct the script before publication | +| QML_SCRIPT_UNSUPPORTED | error | Classic Qt directives occur in an ECMAScript `.mjs` module | Use supported classic `.js` directives or supported ESM syntax | +| QML_SCRIPT_LIMIT | error/failure marker | Imported script exceeds 5 MB, 100,000 AST nodes/depth 256, or overlay graph exceeds 256 files | Reduce input/accepted dependencies or extend a measured bounded profile | +| QT_CPP_READ / QT_CPP_ROOT | error | Unreadable UTF-8 source or source outside the accepted root | Restore readable accepted source; retry without expanding the scan | +| QT_CPP_PARSER / QT_CPP_SYNTAX | error | C++ parser failure or incomplete supported Qt syntax | Restore parser dependency or correct the source | +| QT_CPP_LIMIT / QT_LIMIT | error | Bounded source, AST, parameters or semantic transport exceeds its limit | Reduce input or extend the measured profile | +| QT_METADATA | rejection prefix | Invalid versioned Qt literal transport | Re-extract valid source facts; the join guard rejects publication | +| WATCH_SOURCE_IDENTITY_FAILED | error/rejection prefix | An already admitted nonsemantic watch input has unavailable, foreign or nonregular physical identity, or a changed source was not admitted | Restore the contained regular source and retry; reject partial invalidation and retain the prior product cohort | +| SOURCE_INPUT_IDENTITY_FAILED | error/rejection prefix | Native spelling admission for an existing Windows file fails, returns unusable bounded output or changes physical identity | Restore filesystem access and retry; extraction rejects before workers/cache, and existing manual/watch guards retain prior products | + +Failures emit no authoritative declarations/edges. CLI/watch reject before graph +reconciliation, reports/HTML, root marker and manifest updates. Prior bytes remain +intact under forced, equal-count and edge-only losses. Earlier scan/stat bookkeeping +is outside that boundary. QML and supported Qt metadata bypass AST cache +reads/writes. Native syntax in an explicit Qt context also bypasses syntax cache; +plain generic C++ keeps its existing portable cache. INC-QML-06 fingerprints installed +parser/package/fact/policy versions, ordered import roots and admission configuration. +Qt/provider/script/configuration changes conservatively refresh accepted code. +Generic JS keeps its existing extraction/cache-bypass policy; overlays are +source-owned QML facts rebuilt for the current run. + +Coverage codes are metadata on source-owned sites, rather than parse/write +failures. Reasons include missing roots/versions or members, duplicate providers, +internal exports, missing singleton pragma, resource `prefer` paths without an +index, inheritance/module/alias cycles or limits, JS lexical shadowing, computed +targets, reassigned callable values, runtime `Connections` targets, mixed-handler +style suppression and attached-provider absence. A coverage gap can coexist with +successful extraction; its site has no arbitrarily selected target edge. + +Native registration coverage belongs to `QtQmlBridgeIndex`, not the parser or +graph writer. `native_class_definition_unavailable` with status `unavailable` +means the accepted class ID has no source record explicitly proving a complete +class body. Forward declarations and legacy records without `is_definition` +cannot establish a provider. Reanalyse accepted sources with Qt policy 6 and +AST cache schema 7; if the +body is outside the accepted corpus, supply that source through the supported +scope rather than guessing a target. A complete body without accepted meta-object +evidence retains `unsupported` / `native_class_metaobject_unavailable`. +`test_req_qml008_ac02_missing_body_proof_cannot_become_a_native_provider` covers +forward-only and legacy metadata rejection. Genuine competing complete bodies +retain ambiguity; source-owned unresolved facts remain visible in exports. + +Ownership recovery does not add a new success or write-failure code. Exact +canonical definition provenance and complete class evidence permit normal source +edges; missing or conflicting evidence does not. Qt policy 6 forces unchanged- +input refresh after the correction. A malformed native refresh still reaches +`QT_CPP_SYNTAX` and the existing publication guard, preserving the prior graph, +manifest, Qt stamp and root marker even under force. Corrected retry commits the +new state only after normal graph/manifest completion. +Borrowed unchanged-header bodies preserve their original source/span identity; +two representations of that same body do not create a false ambiguous owner. +Distinct source bodies still remain ambiguous. The accepted-context regressions +in `test_qt_cpp_context_ownership.py` cover both outcomes without mutating borrowed +data or relaxing the complete-body guard. + +Literal transport permits at most 50 string companions, each at most 512 encoded +bytes. The getter validates companion-map/field types and base64/UTF-8 before +lookup. `QML_METADATA` is the low-level rejection prefix, not a separate logger; +the owning extraction/join guard controls the public failure marker. Script-file +fan-out limit is retained as `qml_failures` even where no parser diagnostic is +available. Force cannot override either marker at publication. + + +INC-QML-05 metadata codes QML_PROJECT_READ/LIMIT, QML_PROJECT_UNSUPPORTED, +QML_CMAKE_SYNTAX, QML_QRC_ENTITY/PATH/SYNTAX and QML_TYPES_SYNTAX/UNSUPPORTED describe bounded read/root, +work, unsupported build/type and resource XML failures. Failed/partial accepted +metadata reaches the existing publication guard. QML_TYPES_CONFLICT is a warning +that retains source and generated facts. Duplicate providers/aliases are coverage +ambiguity, not permission to choose the first record. + + +Qt configuration errors use bounded QT_CONFIG / QT_CONFIG_LIMIT messages and reject +before publication. Incompatible/missing .qt_analysis.json forces a refresh; it +never authorizes reading a previously accepted/deleted provider. State is committed +after graph and manifest success. Native scoped root mismatch uses the existing +QML_ROOT_MISMATCH rejection and retains prior durable outputs. + +INC-QML-07 export direction validation uses `QT_EXPORT_DIRECTION` when stored logical +endpoint markers do not name the current accepted edge pair. Correct/re-extract +the graph before exporting; marker text cannot authorize a different endpoint. +Consumer coverage displays unresolved site reasons without converting them into +parser failures or a successful runtime dispatch claim. Presentation omissions +and live database gaps are explicit in [EXPORT_MATRIX.md](EXPORT_MATRIX.md). + +Adoption syntax recovery for valid empty parameter defaults, standalone +`Q_UNUSED` statements and numeric digit separators changes neither diagnostic +IDs nor failure ownership. +Supported input passes the existing syntax stage; incomplete parentheses, damaged +defaults, malformed source and unsupported ordinary call/value contexts retain +`QT_CPP_SYNTAX` where applicable. Recovery changes only a bounded parse view, +never source files or graph-write protection. See DESIGN.md for ownership and +IMPLEMENTATION.md for executed positive/rejection and original-byte regressions. + +## HTML view diagnostics + +Upstream disposition: these generic grouping/view diagnostics are deferred with +REQ-QML-019. They describe the frozen viewer extension, not diagnostic contracts +of the baseline HTML preparation retained in this submission. Qt parsing, +resolution, retention and publication diagnostics elsewhere remain in scope. + +These CLI errors belong to HTML view preparation/publication; they do not describe +Qt source parsing or an application's runtime import state. Messages retain safe +stage/recovery guidance and omit raw backend exceptions or source content. + +| Code | Severity / owning boundary | Outcome and recovery | +| --- | --- | --- | +| `HTML_GROUPING_INVALID` | Error / HTML grouping preparation | Saved membership cannot be recovered as a complete partition, or local clustering fails. Prior HTML/graph are retained. Inspect grouping/backend configuration and retry with accepted graph data. | +| `HTML_VIEW_UNAVAILABLE` | Error / HTML aggregate projection | A bounded useful community view is unavailable or explicitly skipped. Prior HTML/graph are retained. Inspect the partition or choose a focused graph. | +| `HTML_VIEW_FAILED` | Error / HTML preparation or publication | The prepared view exceeds the supported aggregate limit, or output replacement fails. Prior HTML/graph are retained. Use a focused graph for oversized views; check output access for publication failure, then retry. | + +The CLI exits nonzero for these outcomes and cannot announce a new written file. +Successful retry uses the normal atomic writer. Clustering labels and source +payloads stay local; HTML aggregation identifies omitted occurrence-level facts. + + +Constructor/source-containment corrections add no diagnostic code or persistence +boundary. Rejected constructor class proof retains source-site evidence with +unavailable native ownership; it is different from a parser failure. Unknown +emission targets and unproved QML handles retain existing coverage reasons. +Malformed native input or failed joins still reject publication with the existing +stage-specific errors. AST schema 7 retires incompatible caches; prior valid +graph/manifest/root/Qt state survives a failed refresh. Corrected retry uses the +normal publication sequence. In the frozen viewer extension, internal-only or unlinked community counts +are successful view states, not HTML publication failures. That generic count +contract is deferred for this contribution. + +## Project-membership coverage (INC-QML-14) + +INC-QML-39 compares supplied input identity inside the canonical root. Valid +short-path and parent-directory aliases no longer cause a false join failure. +Foreign/conflicting input identity retains the bounded `QML_METADATA` rejection +and owning `QML_RESOLUTION_FAILED` publication guard. INC-QML-41 guards diagnostic +construction against persistent path-resolution failure. Every affected source +is annotated; raw backend bodies are omitted. The complete safe relative label +has a 160-character ceiling. Foreign/unavailable, overlong, C0/C1/DEL and Unicode +line/paragraph-separator context uses an empty `source_file` string, without +truncation or lookup authority. Guarded canonical context precedes lexical +contained fallback; neither diagnostic form authorizes source membership. +Rejected updates preserve prior graph/manifest/root/Qt-state products; restore accessible, +matching accepted inputs before retrying. Path metadata resolution neither opens +new source content nor expands the corpus. Complete bounded failure annotation, +existing `QML_GRAPH_PRESERVED` writer outcome and retained products pass locally +through direct/manual/watch production paths; hosted proof remains pending. + +The initial INC-QML-14 profile is locally verified. INC-QML-39 adds accepted +input-alias coverage; focused/broader source regressions pass. A fresh artifact +initially reproduces the facade gap corrected by INC-QML-40; fresh installed alias +smoke now passes. Current hosted proof remains pending. Qt policy 19/schema 12 +provide the INC-QML-40 upgrade; INC-QML-41 adds no further epoch or code. +`membership_resolution` sites retain the +source declaration, original span, bounded evidence/candidates and status/reason. +Missing, duplicate, conditional, generated or out-of-root targets retain ordinary +`QML-RESOLVE-001` coverage semantics with no target edge. Unresolved membership is +different from a malformed source or failed graph write; the site does not permit +reading a new path or executing a build/QML/plugin to obtain a target. + +Invalid metadata/transport or unexpected projection failure uses the existing +reader diagnostics or `QML_RESOLUTION_FAILED` pipeline guard. No new parser code, +logger or publication bypass is introduced. Prior durable products remain subject +to the normal failure-retention boundary, including force. The initial +INC-QML-14 Qt policy 6 with AST schema 7 has local same-version refresh, failed-refresh retention, +corrected retry and repeat proof. The reviewed installed public fixture rejects +malformed qrc under force/partial options while retaining graph, manifest, analysis +state and root marker; repaired and repeated updates succeed. Earlier policy-5 +evidence remains tied to its original revision. Continue using the installed artifact's normal +`graphify update .` workflow after installation rather than editing graph facts. + +## Follow-up semantic audit + +The [follow-up audit](FOLLOWUP_AUDIT.md) found wrong targets labeled resolved and +literal loaders omitted from special source facts. Successful publication or an +absence of parser/join exceptions does not detect those semantic failures. +INC-QML-17–20 now have bounded source/artifact correction evidence. The original audit added no diagnostic code, +runtime failure policy or persistence bypass. + +For each correction, diagnostic review must distinguish an evidenced successful +target, an unsupported/ambiguous receiver or declaration, a genuinely failed +analysis, and failed publication/recovery. Unavailable identity retains source +provenance and no guessed target edge; actual failures retain prior durable +products under the existing guards. Stable new reasons/codes, severity, owning +boundary and retry behavior must be specified and tested before implementation +claims. Native browser/device gaps have their own +[system review procedure](VIEWER_SYSTEM_REVIEW.md), without fabricated graph errors. + +## Receiver-owned coverage (INC-QML-17) + +The reverse-access index retains successful targets only for accepted receiving +objects and member roles. Unsupported search flags use +`find_child_options_unsupported`; missing or rejected construction evidence uses +`construction_parent_evidence_missing`, `construction_parent_unestablished` or +`object_parenting_unestablished`; missing descendants retain +`object_name_unavailable`. Duplicate accepted descendants remain ambiguous. +Possible QML parenting changes use `component_parenting_mutated`; observed C++ +setParent attempts retain `runtime_parent_mutation` source sites and invalidate +only an established receiver's component tree. Readonly writes retain +`readonly_property`. These are bounded coverage reasons under the existing +resolution contract, not new parser errors or runtime validation. + +Accepted member access preserves property read/write versus invokable-call +direction and native endpoint roles. Unknown source ownership retains no guessed +edge. Genuine parse/join failures still use the existing stage guards; an actual +graph replacement failure preserves accepted graph, manifest, root and Qt stamp. +Repair/retry uses the normal update/watch sequence. Policy 7 refreshes unchanged +accepted Qt inputs; no writer, schema migration or corpus execution is introduced. + +## Declaration identity rejection (INC-QML-18) + +Engine/provider/handle identity is owned by the accepted C++ syntax index. Its +`status` and `reason` fields distinguish missing declarations, unsupported +expressions, duplicate declarations, conditional/deferred ownership and observed +reassignment. The context adapter propagates a precise role identity failure; +mixed initial-property failures retain the aggregate unavailable reason instead +of falsely attributing all rejected entries to one cause. Expired source provider +lifetime cannot create a binding. + +These bounded source-site diagnostics use existing Qt metadata transport, severity +and publication contracts. They do not introduce a logger or success warning. +Recovery is correction of source/provenance followed by ordinary update; retries +do not grant identity. Policy 8 forces old derived overlays to refresh. Actual +analysis/write failures retain the previous four durable graph products. + +## Native type authority (INC-QML-19) + +The type index retains `native_type_expression_unsupported`, +`native_type_binding_ambiguous`, `native_type_binding_conditional`, +`native_type_alias_cycle_or_limit`, `native_type_alias_unsupported`, +`native_type_declaration_not_visible` and `native_type_included_alias_unavailable` +reasons. Registrations retain unavailable type evidence; event sites retain +unresolved endpoint authority. Corrupted accepted alias/include transport raises +`QT_METADATA` instead of selecting a global target. Existing Qt diagnostic and +failed-publication contracts apply; no separate logger or execution is added. + +Construction ancestry rejection remains a source parenting-unavailable result. +It preserves accepted registration/member facts while refusing an unproved child +tree. A shadowed findChild filter retains `find_child_type_unsupported`. Correct +the source/provenance and run ordinary update; policy 9 refreshes old overlays. + +## Literal loader rejection and recovery (INC-QML-20) + +Successful literal routes retain resolved source ownership, with no claim of +runtime object creation. Unsupported constructor identity/arguments retain +`loader_constructor_identity_or_overload_unestablished`; method/root overloads +retain `loader_overload_unestablished`. Missing component engine authority retains +`component_engine_unestablished`. Conditional loads use `conditional_component_load`, +unknown URL expressions `computed_url`, and absent/duplicate source selection +`unique_source_loader_unestablished`. Existing declaration/lifetime reasons remain +more precise than a generic provider failure. These are bounded resolution +status/reason fields, not new exception codes or a separate logger. + +The owning collector/index/resolver preserves original source context and no +guessed target. Correct the source/configuration and use ordinary update; policy +10 retires old derived facts. Actual parse/join/write exceptions use existing +stage diagnostics and publication guards. Manual/watch tests force parse and +replace failure, preserve the four prior products and prove repaired retry. + +Full Windows testing found an unchanged atomic-write exception: a read-only +destination may be replaced before displaced-file cleanup raises. INC-QML-23 +retains that integrity/recovery gap under REQ-QML-018-AC06. Prior generic retention +statements describe the executed failure profiles, not this failing OS boundary. + +## Retained publication and source rejection (INC-QML-21–27) + +Qualified source proof, SDK-name shadows and invalid native notification evidence +retain unresolved/ambiguous source results without guessed targets. Static API +identity rejection uses `reflection_api_type_unestablished`; loader overload/type, +declaration-identity and unavailable signal reasons retain their owning collectors. +Complete-source namespace overflow grants no partial join authority. Successful +resolution adds no warning; source corrections recover through normal update. + +| Code | Severity and owner | Outcome and recovery | +| --- | --- | --- | +| `GRAPH_PUBLICATION_FAILED` | Error; caller-owned product preparation/commit | Update returns failure and prior accepted products survive recoverable faults. Correct permissions/source/output conditions and retry the ordinary update. First-build failure accepts no product cohort. | +| `GRAPH_PUBLICATION_RECOVERY` | Integrity error; publication rollback | A second restoration failure leaves recovery snapshots on disk. Stop accepting the mixed output, restore the complete prior cohort from the retained owned snapshot, correct the OS failure and rebuild. An automatic retry alone is not restoration proof. | +| `GRAPH_PUBLICATION_CLEANUP` | Warning after committed success; error after an uncommitted run; owned transaction cleanup | Accepted committed products remain coherent. Retained scratch files need cleanup after releasing locks/permissions; failed preparation preserves prior products. Cleanup failure never retroactively turns coherent committed success into false rollback evidence. | + +Diagnostics expose bounded stages/codes, not private filesystem paths or serializer +bodies. Internal exception chaining retains the failure cause for local debugging. +Owned `.gfy-publish-*` directories contain `old`, `stage` and `restore` products. +On recovery, retain the `old` snapshot until every required destination has been +restored and read back; remove owned scratch only after that verification. A +missing original product is restored to absence. Follow configured output symlink +targets; never delete or move an unchecked computed path. Do not publish these +local snapshots because their contents belong to the analyzed project. + +Individual writer restore failures likewise preserve a recovery backup when one +exists, or explicitly report that no prior backup exists. Ordinary read-only +destinations reject before displacement. The transaction does not promise atomic +visibility across products under process termination, power loss or racing writers. +Successful AST cache facts may survive publication failure; graph/root/manifest/ +Qt stamp remain the accepted cohort. Schema 9/policy 12 retire older analysis facts; +rollback/downgrade uses the matching analyzer and a fresh build. + +### Native property notification outcomes + +`QtMemberViews.property_notify` owns `native_property_notify_unavailable`, +`native_property_notify_provenance_unavailable`, +`native_property_has_no_notify_signal`, `native_property_notify_accessor_not_unique` +and `native_property_notify_is_not_signal`. They retain unavailable, ambiguous or +unsupported resolution status, original handler/property context and no guessed +subscription. Successful admission records `native_property_notify_signal` with +the actual canonical native signal. These are source-resolution reasons under +the existing QML diagnostic boundary, not parser/write exception codes. Correct +registration, property or accessor evidence and run ordinary update; retries do +not grant missing provider or native signal authority. + + +## Inherited endpoint coverage (INC-QML-11) + +The event index retains bounded coverage reasons under the existing Qt resolution +status contract: `inheritance_identity_unavailable` for missing/incomplete/invalid +canonical ancestry, `inheritance_cycle_or_limit` for a cycle or the 32-class +budget, and `inheritance_access_unavailable` for absent/invalid/nonpublic base +access on external member-pointer routes. Successful lookup preserves the exact +declaring member and source occurrence; it adds no warning. Shadowed or competing +members retain existing unavailable/ambiguous outcomes and no guessed edge. + +These are source-analysis coverage outcomes, not parser or persistence failures. +Correct the source/provenance and run ordinary update; retry does not establish +missing authority. Actual parser/join/write failures still use the existing stage +guards and publication transaction. Executed malformed-input and real read-only +manifest failures preserve previous products and allow repaired retry. Policy 13 +refreshes old derived ancestry. INC-QML-28 separately preserves explicitly spelled +emission occurrences when declaration evidence is absent. + + +## Constructor proof rejection (INC-QML-15) + +Accepted signatures retain exact constructor/source ownership without new success +warnings. Unsupported/conditional types, duplicate bodies/signatures, invalid +span/ID/encoded signature, missing admitted dependency and potential local angle +include retain no canonical native owner. Generic `cpp_constructor` facts expose +`ambiguous`, `type_authorized` and `owner_bound`; existing Qt class-binding status +and reasons report the resulting unavailable authority. These are coverage +outcomes, not parser exceptions. Correct source/evidence and perform ordinary +update; retries do not grant missing type identity. + +Malformed source and genuine read/cache/join/write exceptions use existing stage +contracts. Real read-only and late OS replacement failure tests preserve previous +accepted graph products and existing successful content-keyed cache entries, +then prove repaired retry. Schema 10/policy 14 retire old name-collapsed facts; +[D20](ARCHITECTURE.md#d20--constructor-signatures-authorize-identity-and-joins) +defines rebuild/downgrade behavior. No new logger, compiler execution or diagnostic +code is introduced. + +## Explicit source emission outcomes (INC-QML-28) + +An accepted annotation creates an observed emission without a success warning. +Missing, ambiguous, private or computed receiver/endpoint evidence retains the +site's unavailable status and existing signal-resolution reason. A local marker +override or inert/unevaluated syntax grants no explicit role. Same-spelled APIs +do not override an accepted emission mechanism. These are source-coverage outcomes, +not generic parser failures. Existing QT_CPP_SYNTAX and stage-specific update/write +errors still reject genuine failure and preserve prior accepted products. Repair +source or declarations and retry normally; no new code or runtime delivery claim +is introduced. Policy 15 refreshes unchanged derived sites. + +## qmake adoption rejection (INC-QML-08a) + +Supported literal metadata and header classification add no success warning. +Relevant unevaluated metadata uses the existing QML_PROJECT_UNSUPPORTED boundary. +New bounded reasons are `qmake_pwd_reassignment`, `qmake_scope_limit`, +`qmake_path_limit` and `qmake_path_outside_accepted_root`. Conditional/expanded, +unknown statement and unbalanced scope reasons remain applicable. Ignored build +settings and observed assignments are coverage facts, not branch execution. +Repair the source or explicitly configured safe roots and retry ordinary analysis; +expansion does not become supported by force. Parse/root/publication failure keeps +the existing product-cohort and successful source-cache retention contract. Header +classification supplies no Qt semantic authority and introduces no separate code. + +## Typed API coverage and integrity (INC-QML-08b) + +Accepted static API/provider/subscription joins add no success warning. Unsupported +declarator shapes retain `api_type_shape_unsupported` or +`provider_type_shape_unsupported`; conditional return authority retains +`conditional_api_type`. Missing, conflicting, cyclic and over-depth child chains +remain unresolved/unsupported with `context_child_property_unavailable`, +`context_child_type_unavailable_or_cycle` or `context_type_chain_limit`. Invalid +root/provider provenance retains existing bounded identity reasons. Serialized +type, owner, callable, source range, READ/NOTIFY and callback proof is independently +revalidated; corrupted proof cannot restore a link merely because a target exists. + +Correct declarations, lexical bindings or source-backed metadata and retry normal +analysis. Rejection grants no runtime allocation/conversion or alias authority. +Genuine parser/resolver/publication errors retain their owning stage diagnostics +and prior products, with corrected retry and repeat evidence. Source-cache entries +may be added after successful extraction but do not accept graph publication. +No new diagnostic code, logger or corpus execution is introduced. + +## Direction rejection and publication (INC-QML-30/31) + +Accepted logical pairs add no warning. Read-only affected queries ignore invalid +complete/partial/foreign/ill-typed pairs, preserving graph bytes and source-owner +boundaries. Typed reload leaves explicitly corrupted markers intact for rejection; +it restores direction only when both markers are absent. + +JSON serialization reports the existing stable **QT_EXPORT_DIRECTION** code at +the error severity, with bounded repair/re-extract guidance, and returns failure +before writing. Native directed contradictions also reject; force cannot bypass +the integrity check. The diagnostic includes no raw endpoint, path or source data. +External Qt preflight/Cypher keep their established complete-marker interpretation +on bidirectional storage wrappers while rejecting invalid pair membership and +types. JSON's native directed persistence is the stricter boundary. + +Prior JSON bytes and the coordinated graph/HTML/report/manifest/checkpoint cohort +remain retained on rejection. The established owning publication stage still +reports an actual rollback or cleanup failure separately. Correct the source or +transport and retry normally; successful extraction/cache writes alone do not +accept publication. Real manual/watch rejection, nonempty source-cache retention, +repair and byte-identical repeated output are ordinary regression evidence. + +## Initial raw-update repetition (INC-QML-32) + +An unchanged first-repeat run reports the existing no-change completion and +preserves accepted graph bytes/mtime. Representation-only empty-list omissions +add no warning. Meaningful diagnostics, malformed values and changed source facts +remain observable; genuine parser/publication failures keep their existing owning +codes and prior product retention. Corrected source or writable output retries +normally. No new diagnostic, writer or rollback owner is introduced. + +## File-role rejection and candidate preflight (INC-QML-33/34) + +A malformed or foreign generic C++ file record grants no membership or containment +endpoint. Membership retains its existing unavailable/duplicate reason; a file +link cannot repair an unresolved native callable or type. Correct source/metadata +and repeat normal extraction. No additional source reads or corpus execution occur. + +QT_EXPORT_DIRECTION also applies before raw publication and clustered no-change +acceptance. Its error identifies invalid logical-pair transport without exposing +the pair's arbitrary strings. The updater returns failure, retains the prior +product cohort and existing successful AST cache entries, and accepts no candidate +checkpoint. Force cannot bypass it. Repair/re-extract then retry normally. + +## Relationship and occurrence integrity (INC-QML-35–37) + +Unproved prior imports cannot authorize orphan retirement. Complete extraction +may remove their deleted source edge while conserving the unproved placeholder; +candidate integrity applies to surviving/new relationships. Still-referenced +external endpoints remain. Cleanup emits no new warning on successful completion. + +A relationship inconsistent with its source mechanism grants no typed bridge. +The graph builder rejects it before generic relationship handling, keeping the +source occurrence and uncertainty without inventing a call or subscription. +Corrupt Qt literal transport retains QT_METADATA/QML_RESOLUTION_FAILED handling; +transport limits are not relaxed. Per-occurrence annotation prevents successful +source analysis from generating recursive literal maps or false parse failures. +Genuine parser, resolver and publication failures retain their existing bounded +diagnostics, prior product cohort and successful AST cache entries; repair retries +the ordinary path. Runtime Qt execution remains outside analysis. +## Typed Qt source-identity transport — INC-QML-50 + +SOURCE_INPUT_IDENTITY_FAILED retains the source-admission owner and existing +severity/recovery contract when a direct QML or Qt metadata reader encounters +native spelling rejection. The typed producer returns no accepted facts and +preserves that code through its existing diagnostic/result transport; it does +not relabel native admission as QML_ROOT or metadata read success. Context remains +bounded and safe, and unexpected backend exception bodies are omitted. Repair +filesystem access and retry through the normal producer/update boundary; prior +accepted products remain protected by existing transactional publication. + +Physical resolution precedes lexical normalization for POSIX link/`..` spelling. +Containment rejection still has its separate root/input contract. Neither lexical +source names nor a matching physical target grant member/module/native endpoint +authority. Exact native/POSIX failure and retry assignments belong to traceability. diff --git a/docs/qt-qml/EXPORT_MATRIX.md b/docs/qt-qml/EXPORT_MATRIX.md new file mode 100644 index 0000000000..dba9942299 --- /dev/null +++ b/docs/qt-qml/EXPORT_MATRIX.md @@ -0,0 +1,156 @@ +# Qt/QML export capabilities + +Status: INC-QML-07 source implementation. This matrix describes accepted static +source facts, not Qt runtime delivery or database-service verification. The +canonical requirement is [REQ-QML-013-AC03](../REQUIREMENTS.md). A presentation export +is an inspection aid; retain `graph.json` when exporting one. +INC-QML-14 adds locally verified source-owned project/resource membership +projection under REQ-QML-020 for its bounded static profile. Current producer/ +consumer evidence is recorded separately from earlier export results. + +| Consumer or format | Retained Qt/QML information | Direction and reload | Explicit limitations | +| --- | --- | --- | --- | +| Graphify JSON | Node/occurrence identities, complete `metadata.qml`/`metadata.qt`, literal transport, original source spans, relation/context/confidence, existing attributes | `to_json` and `load_node_link_graph` preserve typed source direction for both default undirected and directed graphs | Graphify's normal internal-marker sanitation and persistence guards apply. This is the canonical semantic backup. | +| Directed GraphML | Complete metadata and attributes as JSON string properties; source paths/locations, relation/context/confidence, canonical IDs | Directed graph endpoints survive `networkx.read_graphml`; decode JSON-valued properties before semantic lookup | No automatic Graphify GraphML importer. XML-incompatible control characters follow existing export sanitation. | +| Undirected GraphML | Same source facts and JSON-valued properties as directed GraphML | Undirected topology survives; internal `_src`/`_tgt` markers are omitted | Logical dependency direction is not a promised reload contract. Use directed GraphML or canonical JSON for direction-sensitive work. | +| Cypher file, Qt/QML graph | Public scalar node/edge fields; complete nested facts in `metadata_json`, existing attributes in `attributes_json`; source paths/locations, context/confidence | Typed logical endpoints become relationship endpoints and `graphify_source`/`graphify_target`; stable `graphify_fact_key` retains distinct accepted mechanisms | Offline text/payload tests pass. No database engine was executed. Ordinary non-Qt graphs retain the existing writer. | +| Neo4j/FalkorDB direct push, Qt/QML graph | Same metadata/attribute JSON property representation plus existing scalar properties and canonical IDs | Parameterized upserts use typed logical endpoints and a stable relationship fact key | Actual production driver-boundary payloads are tested with recording SDK sinks. Live insertion/readback is unexecuted and outside the advertised static profile. Upserts do not remove stale records; replacing a prior graph needs an explicit database ownership/cleanup policy. | +| Ordinary `graph.html` | Full source-owned Qt/QML payload metadata, existing attributes, a readable semantic projection, locations and typed relationship evidence; projected fields participate in search and detail views | Visual arrows use logical endpoints; actual written payload and detail-rendering JavaScript are tested | Not a Graphify reload format. Community aggregation explicitly reports omitted source details; retain JSON. A pre-existing user search namespace is preserved without treating it as authoritative Qt/QML semantics. No Qt runtime equivalence is claimed. | +| Call-flow HTML | Distinct typed dependency/event/access tables, endpoint IDs, source locations, inferred confidence and runtime-delivery uncertainty | Relationship tables retain source direction; emissions/connections remain separate from direct call tables | Not a full fact or AST backup. Only the selected report sections are included; use JSON for omitted declarations/metadata. | +| Obsidian notes | Human-readable labels, declaration sources/locations, connection labels/confidence and community notes | Wikilinks expose neighborhood relationships | Complete semantic metadata, endpoint proof, edge context and byte spans are omitted. Notes do not establish logical call/event direction or round-trip identity. | +| Obsidian Canvas | Node cards/file links, communities and relationship/confidence labels | Visual endpoint references follow the existing layout exporter | At most 200 highest-weight edges; metadata, source ranges and contexts are omitted. Raw undirected arc order is not a logical dependency contract. | +| SVG | Labels, community colors, topology and extracted/inferred edge styling | Static lines; no semantic importer | Optional `svg` extra required. Source facts, endpoint evidence, context, byte spans and logical arrow direction are omitted. The local SVG test is skipped when the optional renderer is absent. | +| Wiki | Community/declaration orientation, source links and graph-level confidence statistics | Human-readable navigation | Not a complete semantic export. Use canonical JSON for source-site facts and endpoint evidence. | +| General report | Separate QML/Qt source-owned fact counts for resolved, ambiguous, dynamic, unsupported, unavailable and error states, plus other states and invalid metadata | Counts survive canonical JSON reload; the report does not mutate the accepted graph | Counts describe retained source facts, not a percentage of language support. Static resolution does not prove runtime behavior. A graph cannot establish that omitted or failed source extraction succeeded; consult diagnostics and canonical JSON. | + +## Database property contract + +`graphify/qt_export.py` owns the Qt-aware scalar payload and Cypher script. Nested +maps are serialized as exact JSON strings because database properties do not accept +arbitrary nested maps. Consumers decode `metadata_json` to recover the existing +versioned QML/Qt contract and literal transport; no new runtime interpretation is +introduced. Existing attributes remain separately recoverable from +`attributes_json`. Public scalar properties retain their names. + +Qt/QML transport reserves `metadata_json` and `attributes_json`, plus the edge +properties `graphify_source`, `graphify_target` and `graphify_fact_key`. A collision +raises `QT_EXPORT_PROPERTY_COLLISION` instead of overwriting accepted properties. +The complete graph is checked before a direct SDK connection is created or any +index/write operation starts; Cypher validation completes before the atomic file +write. Tests preserve an existing output file and prove no SDK connection is +created on rejection. Other public scalar properties remain recoverable. + +Non-interpolated string literals and escaped property identifiers protect the +offline Cypher text boundary. Direct SDK writers use parameters. The relevant +primary specifications are Neo4j's [property value types](https://neo4j.com/docs/cypher-manual/current/values-and-types/property-structural-constructed/) +and [literal escaping](https://neo4j.com/docs/cypher-manual/current/values-and-types/boolean-numeric-string/). +Transport preservation does not assert a live server accepts every statement. + +The upsert relationship key represents the accepted source/target and current +public fact payload. Identical re-pushes are stable; changed facts can leave earlier +database relationships present. Graph-level clustering/hyperedge collections are +not part of this node/edge payload contract. Graphify has no database-to-Graphify +round-trip importer in this increment. + +## Executed evidence and optional service validation + +`tests/test_qt_qml_export_consumers.py` exercises actual native facade extraction, +directed GraphML readback, repeated native calls/reads through both JSON graph +orientations, and a directed QML-call-to-C++ path with separate call-site and +declaration sources. `tests/test_qt_export_matrix.py` exercises the actual Cypher +file, both SDK writer boundaries, parallel mechanisms, literal escaping, ordinary +writer compatibility, and actual Canvas/Obsidian outputs. Existing graph-database +index tests retain the generic push sequencing contract. Reserved-property +collision cases exercise rejection before file replacement or SDK creation. + +`tests/test_qt_graph_html_payload.py` checks the actual written full HTML payload, +readable details, escaping, preserved user attributes and explicit omission in +community aggregation. `tests/test_qt_html_consumers.py` checks the production +call-flow artifact and separation of events from ordinary calls. +`tests/test_qt_source_coverage_report.py` checks production reports after actual +extraction and JSON reload, including invalid metadata and future-contract guards. + +`tests/test_qt_mcp_consumers.py` and `tests/test_qt_mcp_stdio.py` exercise the +production HTTP and stdio protocol boundaries with the installed MCP SDK, comparing +query/path results and source/confidence evidence with the CLI. This executed +protocol evidence is distinct from the recording database SDK sinks. Exact +commands/results belong in +[VALIDATION.md](VALIDATION.md) and [traceability](../../tests/TRACEABILITY.md). + +Live database verification is explicitly unexecuted. It is outside this static +source-support profile and is not an additional release gate. The recording SDK +tests establish the production payload and invocation boundary, not live engine +acceptance or readback. + +If live integration is later requested and authorized, use an isolated disposable +database, record engine/SDK versions, and export a public native fixture with the +production APIs. Read back canonical IDs and decoded properties to compare exact +metadata, source ranges, logical endpoints, context/confidence and repeated source +occurrences; an identical re-push should retain the same count. Record redacted +failures and clean up only the authorized disposable database. Corpus execution, +production graph replacement and cleanup need their own scope and ownership +decision; no account access or live-service result is assumed here. + + +The aggregated HTML inspector distinguishes canonical internal/external source +edges from distinct neighboring communities. Internal-only metadata groups retain +true source relationships without fake meta-graph self-loops. Source-only callable +containment preserves native class uncertainty and original Qt fact payloads; +counts do not imply resolution or runtime delivery. + +## Project/resource membership correction (INC-QML-14) + +Status: **Locally complete; Verified within the bounded static source profile**. +The source projection adds independent +`membership_resolution` sites owned by accepted CMake/qmake source declarations +and qrc aliases. A declaration contains its site with `qt_membership_site`; only +a uniquely resolved site references an accepted canonical file/component with +`qt_project_source` or `qt_resource_membership`, confidence `EXTRACTED`. Site +status/reason and bounded evidence retain unresolved attempts without a guessed +target. Raw declaration facts and existing module/resource/loader lookup results +stay unchanged. [D15](ARCHITECTURE.md#d15--project-membership-is-independent-of-component-use) +defines this static membership boundary independently of component use. + +Canonical JSON retains those site identities, original spans, module/alias +context and logical endpoints in directed and default-undirected graphs. Written +full HTML exposes the retained source payload; aggregate HTML displays only +actual persisted community relationships and counts, with its existing source- +detail omissions. A membership reference is neither a call nor proof of a runtime +import, plugin load or architectural dependency. Existing export omissions and +database-service limitations remain unchanged. + +Actual build/reload/query/HTML regressions and reviewed installed-artifact +evidence for REQ-QML-020 pass locally and are recorded in +[validation](VALIDATION.md) and [traceability](../../tests/TRACEABILITY.md). The +correction uses Qt policy 6 with AST schema 7 unchanged. The installed public +CMake/qmake/qrc fixture retains six resolved memberships and succeeds through +query/explain/affected/HTML; malformed-resource rejection, repair and repeat pass. +An earlier export or policy-5 artifact does not verify this correction. No new +browser visual, hosted, other-platform, database-service or executable Qt proof is +claimed; other export omissions remain those listed above. + +## Logical direction integrity (INC-QML-30/31/34) + +Affected queries and native JSON use exact logical pairs, preserving undirected +storage reversal while rejecting contradictory directed identity. Serialization +validates before consuming markers or writing. Raw update publication and the +clustered no-change shortcut perform the same preflight before accepting data. +Invalid pairs produce bounded QT_EXPORT_DIRECTION and preserve prior products; +force cannot grant authority. Legacy unmarked graphs retain their prior behavior. + +External Cypher wrappers can store a complete pair independently of wrapper tuple +order; their existing interpretation remains compatible. Partial, foreign or +ill-typed pairs still fail before opening a database connection. Recording SDK +tests prove that boundary; live database acceptance remains unexecuted. Exact +production reload/query/update/writer tests and final artifact evidence belong in +[traceability](../../tests/TRACEABILITY.md) and [validation](VALIDATION.md). + +## Final adoption evidence boundary + +The current bounded Windows source and reviewed installed profile includes +INC-QML-11/15/08 and INC-QML-28–38. Mechanism proof and per-occurrence transport +survive assembly/serialization/reload; typed uses/read/call/connect/disconnect +facts retain their original endpoint roles and direction. Local installed +acceptance, actual CMake/qmake CLI profiles and retained full-gate/platform limits +are recorded in [final validation](VALIDATION.md#final-adoption-delivery). +Earlier revision records do not establish whole-SDK or runtime compatibility. diff --git a/docs/qt-qml/EXPOSURE_CHAPTER_REVIEW.md b/docs/qt-qml/EXPOSURE_CHAPTER_REVIEW.md new file mode 100644 index 0000000000..ec0938a5ba --- /dev/null +++ b/docs/qt-qml/EXPOSURE_CHAPTER_REVIEW.md @@ -0,0 +1,43 @@ +# C++ attributes exposed to QML + +This review maps the official [Qt 6 chapter](https://doc.qt.io/qt-6/qtqml-cppintegration-exposecppattributes.html) +and the [Qt 6.8 profile](https://doc.qt.io/qt-6.8/qtqml-cppintegration-exposecppattributes.html) +to Graphify's static analysis contracts. The chapter was explicitly reviewed on +4 October 2026. Qt API documentation describes runtime behavior; Graphify analyzes +accepted source evidence without creating objects or invoking an engine. + +| Mechanism | Analysis contract and acceptance | +| --- | --- | +| Type visibility | Source registrations and accepted CMake/qmake module evidence select native providers; REQ-QML-008-AC01/AC03 and REQ-QML-009-AC01. | +| Properties and notification | `Q_PROPERTY` members retain accessor/NOTIFY evidence. INC-QML-27 maps `onChanged` to the actual accepted NOTIFY signal, whose name may differ; REQ-QML-008-AC02 and REQ-QML-007-AC03. | +| Callable attributes | Public slots and `Q_INVOKABLE` members retain their native identities; unsupported or ambiguous overloads remain unavailable; REQ-QML-008-AC02/AC03. | +| Signal subscriptions | Direct handlers and `Connections` preserve native signal direction. Explicit handler parameters own their lexical bindings; legacy block handlers have their distinct implicit parameter rule; REQ-QML-007-AC01/AC03. | +| Object and grouped attributes | Accepted declared member/provider identities govern source lookup. INC-QML-08b adds exact pointer/lvalue-reference factory and child-service API paths, with independently revalidated accessor/NOTIFY and scoped callback proof; REQ-QML-018-AC03/AC04/AC06. Runtime construction, ownership and arbitrary object selection are not inferred; REQ-QML-005-AC03/AC04 and REQ-QML-017-AC02. | +| Lists, gadgets and value behavior | `QQmlListProperty`, `Q_GADGET` value semantics, ownership transfer and coercion require separate semantic evidence. Generic C++ declarations alone do not establish those mechanisms. | +| Runtime method behavior | Dynamic overload selection, method rebinding and `NativeMethodBehavior` are outside the bounded source profile. | + +The [API-family inventory](QT_API_COVERAGE.md) and [macro accountability table](QT_API_COVERAGE.md#named-macro-accountability) +are the completeness checklists. The [requirements](../REQUIREMENTS.md) define +observable behavior; [traceability](../../tests/TRACEABILITY.md) and +[validation](VALIDATION.md) distinguish executed acceptance from exclusions and +system gaps. Reviewing this chapter does not establish support for every Qt SDK +function or runtime behavior. + +The concrete missing property-notification relationship was corrected by +[INC-QML-27](PLAN.md#inc-qml-27--native-property-notification-handlers). +INC-QML-08b extends that source mechanism through typed context child services; +INC-QML-30/31 preserve direction and reject corrupt serialization. Exact local +source/installed evidence and unexecuted platform lanes remain assigned in validation. +Existing macro spelling checks remain independently applicable. No additional +runtime dependency, corpus execution or automatic exposure of unregistered classes +is introduced. + +## Final adoption evidence boundary + +The current bounded Windows source and reviewed installed profile includes +INC-QML-11/15/08 and INC-QML-28–38. Mechanism proof and per-occurrence transport +survive assembly/serialization/reload; typed uses/read/call/connect/disconnect +facts retain their original endpoint roles and direction. Local installed +acceptance, actual CMake/qmake CLI profiles and retained full-gate/platform limits +are recorded in [final validation](VALIDATION.md#final-adoption-delivery). +Earlier revision records do not establish whole-SDK or runtime compatibility. diff --git a/docs/qt-qml/FOLLOWUP_AUDIT.md b/docs/qt-qml/FOLLOWUP_AUDIT.md new file mode 100644 index 0000000000..3966fdab64 --- /dev/null +++ b/docs/qt-qml/FOLLOWUP_AUDIT.md @@ -0,0 +1,292 @@ +# Qt/QML follow-up audit + +## Upstream submission scope + +The [scoped upstream contribution](UPSTREAM_CONTRIBUTION.md) retains bounded Qt/QML analysis +and its source, incremental, publication and consumer contracts. REQ-QML-019 +and REQ-QML-021, D12/D16, and the generic viewer portions of INC-QML-09/13/16 +are deferred for this submission. Native Codex REQ-CORE-001-AC01/AC05 and the +broad fork repository policy are also deferred; the baseline installer and +repository policy apply. Constructor and source-containment work in INC-QML-13 +remains in scope. + +The [complete frozen fork](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4) +and its recorded 8b6c9d2 source/runner evidence retain their own revision scope. +Deferred feature sections and their exact tests below are historical evidence, +not implemented or verified contracts of this narrower contribution. A changed +contribution head requires fresh applicable checks; prior totals, skips and +hosted results do not establish those checks. + +Date: 4 October 2026. Inspected implementation: +`95adbdc165f44a96bf275a7870bb1da4d82a5bea`, on the existing feature branch. +The baseline audit was findings-only. Its original observations and reproduction +results remain historical evidence. INC-QML-17–20 subsequently correct the bounded +receiver, engine, alias and loader forms; current dispositions and new gaps below +are separate from the original snapshot. +The [foundation audit](AUDIT.md) retains A01–A09; findings below continue that sequence. + +## Graph navigation and independent evidence + +`graphify update .` refreshed the repository graph without model/API extraction. +The navigation snapshot contains 20,421 nodes, 42,848 edges and 1,091 communities; +its HTML community projection contains 1,091 nodes and 2,819 edges. The report +records inspected commit `95adbdc1`. The graph JSON SHA-256 is +`5847eb480b00d170ea5ec14454be578711e266c3950dd29d3f8929635bd0d55d`. +Generated outputs remain ignored and later refreshes may change these counts. +Open `graphify-out/graph.html` and use community filters or scoped search to review +the codebase. Graph correctness is established separately from this visualization. + +Scoped queries located native event, QML bridge, incremental and HTML owners. +`graphify explain QtEventIndex`, `graphify explain resolve_qt_qml` and +`graphify explain qt_analysis_state` supplied narrower navigation. Broad query +results were truncated; truncation does not establish absent code or relationships. +No wiki index was available. Embedded viewer JavaScript also needs emitted-script +checks because Python AST navigation does not describe its interaction behavior. + +Independent synthetic fixtures exercise production extraction, graph assembly, +JSON publication and reload. They run offline without a Qt SDK or corpus execution. +The current source environment uses Python 3.12.14, tree-sitter 0.25.2 and +tree-sitter-language-pack 0.11.0. No private application source is retained here. + +## Confirmed correctness findings + +### A10 — P1: Reflective member lookup crosses QObject ownership + +A QML root declares `rootOnly` and `rootAction`; a child named `left` declares +neither. C++ reads/writes `left->property("rootOnly")` / `setProperty`, and invokes +`rootAction` on `left`. The analyzer marks these accesses resolved to the root's +declarations and preserves their target edges after JSON reload. + +`QtQmlAccessIndex.member` delegates to QML lexical member lookup, which can fall +back to the component root. C++ reflection must use the receiving object's member +and accepted type/inheritance evidence. QML lexical visibility does not prove a +QObject owns that property or method. Unknown dynamic properties remain uncertain; +they cannot authorize a definite link to another object's declaration. +The [Qt QObject property contract](https://doc.qt.io/qt-6.8/qobject.html#property) +defines property access on the receiving object. + +Affected acceptance: REQ-QML-017-AC02/AC04. Owner: reverse-access resolver maintainer. +Correction: [INC-QML-17](PLAN.md#inc-qml-17--receiver-owned-reflection-and-child-lookup). + +### A11 — P1: findChild searches outside its receiver and ignores depth + +`left->findChild("right")` resolves a sibling of `left`. +`Qt::FindDirectChildrenOnly` can also resolve a grandchild. Persisted +`qt_cpp_qml_find_child` edges preserve both incorrect selections. +`QtQmlAccessIndex.find_child` searches the component-wide object-name index, +excluding only the receiving object. Its caller does not enforce the explicit +depth option. The [Qt findChild contract](https://doc.qt.io/qt-6.8/qobject.html#findChild) +restricts lookup to the receiver's children and the requested depth. + +Source containment is not universal proof of runtime QObject parenting. The +correction must define accepted static parenting evidence and reject or retain +uncertainty for reparenting, unsupported options or insufficient tree evidence. +Affected acceptance and owner are the same as A10; INC-QML-17 covers both. + +### A12 — P1: Distinct engine declarations share context exposure + +An inner block constructs `engine` and supplies `backend` through its root context. +After that block, a different declaration named `engine` loads the QML component. +The analyzer joins the earlier provider to the later engine's component and +publishes a `qt_context_member` edge. A matching receiver spelling inside one +function is insufficient engine identity. + +`qt_context_bindings.resolve_context_bindings` indexes loads by enclosing callable +and receiver text. Provider/load joins need declaration identity, lexical lifetime, +assignment and conditional evidence. The [Qt context contract](https://doc.qt.io/qt-6.8/qqmlcontext.html) +places contexts within their owning engine; a different engine's context is not +established by the same variable name. + +Affected acceptance: REQ-QML-017-AC03/AC04. Owner: context-provider integration +maintainer. Correction: [INC-QML-18](PLAN.md#inc-qml-18--lexical-engine-and-provider-identity). + +### A13 — P1: Local C++ type alias binds a global connection endpoint + +Global `Sender` and `Other` declare `changed(int)`. A local +`using Sender = Other` shadows the global type before `Sender *sender` and +`QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept)`. +The analyzer resolves and serializes the global `Sender::changed` endpoint. +Both `using` and `typedef` aliases also create wrong emission endpoints and export +the global class through `qmlRegisterType`, including a wrong QML handler +subscription. Direct unshadowed native/registration controls select their actual +classes correctly; block controls preserve the unshadowed outer calls. + +Native collectors retain type spellings; `QtEventIndex.member` resolves those +spellings without the alias's lexical declaration authority. Unsupported aliases +must remain unresolved rather than selecting the wrong global type. The correction +must either prove the alias target and scope or conservatively reject the join. +Affected acceptance: REQ-QML-008-AC01/AC03 and REQ-QML-016-AC01–AC04. +Owners: native type/registration integration and endpoint maintainers. +Correction: [INC-QML-19](PLAN.md#inc-qml-19--native-endpoint-type-and-alias-scope). + +## Existing gaps and verification limits + +The [Qt API mechanism matrix](QT_API_COVERAGE.md) reviews the official combined +function/macro index as a family checklist. It distinguishes semantic support, +conservative exclusion, generic extraction and omitted/unverified mechanisms. +Complete Qt API coverage is not claimed. Literal engine URL construction and +component loadUrl were reproduced omissions at the baseline under +REQ-QML-017-AC01/AC04. INC-QML-20 corrects their bounded literal forms. + +Multi-level inherited-signal lookup remains INC-QML-11. Typed factory/member +providers and child-service chains remain INC-QML-08; the factory control here +correctly remains unavailable. Exact generic constructor-overload identity remains +INC-QML-15. Same-namespace unqualified native types can remain unavailable while +fully qualified controls resolved at the baseline. INC-QML-19 adds bounded lexical +type authority; same-file qualified canonical collisions remain INC-QML-21. +None is closed by a readable graph, successful parsing or passing persistence checks. + +The confirmed wrong edges survive reload. Cold/warm/manual/watch parity for the new +corrections, installed-wheel proof and consumer revalidation remain exit gates of +their owning increments. Existing lifecycle tests passing does not validate these +new semantic cases. Browser/device behavior remains a separate system gap under +the [navigation review procedure](VIEWER_SYSTEM_REVIEW.md). + +## Reproduction and validation + +The opt-in probes under `tests/audit/probe_*.py` preserve correct assertions against +the inspected audit baseline. Their filenames deliberately keep diagnostic +probes outside default pytest collection. Corrections have ordinary collected +regressions; the newer static-reflection shadow probe remains a failing finding. They +are not passing regressions or release evidence. Move the cases into ordinary +automatically collected tests with each correction; do not xfail or weaken them. + +```text +.venv/Scripts/python.exe -X utf8 -m pytest tests/audit/probe_qt_qml_object_boundaries.py tests/audit/probe_qt_native_type_shadowing.py tests/audit/probe_qt_loader_forms.py -q --tb=short +``` + +Exact case assignments and final outcomes are recorded in +[traceability](../../tests/TRACEABILITY.md#follow-up-audit-scope-corrections). +The final combined public probes produced **15 failed, 7 passed in 3.38 seconds**, +with the existing Hypothesis warning. Thirteen failing variants expose four wrong- +target root causes; two more expose loader omissions. Rejection assertions inspect +actual persisted edges; positive controls remain meaningful and pass. Native +and QML compatibility selection separately passed 60 existing tests in 4.85 seconds. +The targeted lifecycle command was executed in the repository environment: + +```text +.venv/Scripts/python.exe -X utf8 -m pytest tests/test_qt_final_incremental_parity.py tests/test_qt_config_incremental.py tests/test_qt_analysis_state.py tests/test_qt_worker_cache_integrity.py tests/test_qt_graph_persistence.py tests/test_qt_project_membership_updates.py -q --tb=short +``` + +Result: **67 passed in 21.43 seconds**, one existing Hypothesis collection warning. +An earlier isolated setup under `.venv` produced discovery rejections because +corpus admission excludes that ancestor. Those setup failures are not analyzer +regressions and do not establish lifecycle acceptance. + +## Contribution and collaboration compliance + +| Concern | Finding and disposition | +| --- | --- | +| Canonical workspace and ownership | Compliant for this audit: existing feature branch, preserved unrelated work, disjoint probe owners and one documentation/Git integration owner. No checkout switch or remote mutation. | +| Graph-guided orientation | Compliant: refreshed graph, scoped navigation and independent correctness reproductions. No wiki was available. | +| Requirements and evidence | Partial before this audit: broader scope claims conflicted with the new cases. Existing criteria are now explicitly partial/failed for these cases; earlier bounded passes remain historical. | +| Application language and modularity | Extension remains Python; all 149 new handwritten Python files inspected before these probes were within 300 lines. New probes also require that ceiling. Existing documented oversized owners are not refactored in this audit. | +| Generated assistants | `python -m tools.skillgen --check`: 134 artifacts matched. Generator sources and generated outputs were unchanged. | +| Documentation and public data | Initial read-only review resolved 304 local links and 197 exact test references across 18 documents, with all 84 criteria assigned and no detected private-path/project/credential patterns. Final changed-document checks are recorded with this audit handoff. | +| Full repository validation | No applicable full-suite evidence for inspected `95adbdc` was available. CONTRIBUTING.md requires the complete suite for a code contribution. Earlier full-suite proof belongs to `235987b`; selected later suites do not replace it. Future correction delivery must execute current full-suite/lint/type gates. | +| Language integration convention | CONTRIBUTING.md requires new language fixtures and tests in `tests/test_languages.py`. Dedicated QML fixtures/tests exist, but that named file has no QML case. Add a production QML language-contract case before upstream delivery, without replacing dedicated coverage. | +| AI commit attribution | Seven later extension commits from `7b31d59` through `95adbdc` omit the required AI authorship metadata; earlier extension commits use truthful Codex attribution. Future commits must include it. Historical metadata remains a disclosed gap; this audit does not rewrite history. | +| Conventional commit subjects | Initial commits `6e454b7`, `47b422b`, `9fd9cd0` lack the required prefix. Future commits follow the convention; historical commits remain unchanged. | +| Native interaction procedure | Exact fixture/action/evidence/failure/cleanup/owner instructions were missing for REQ-QML-021. The linked procedure supplies the contract; actual execution remains unverified. | +| Inherited dependency-direction debt | Imported `graphify/extractors/markdown.py::_active_scan_root` imports `graphify.extract` and reads ambient `_XAML_ACTIVE_EXTRACT_ROOT`. This predates Qt work and conflicts with the general rule. The scoped exception and extraction exit are recorded in DESIGN.md; it is not permission for new ambient state. | +| PR and protected proof sequencing | No publication, merge or deployment occurred in this audit. Those obligations are inapplicable to this local findings handoff; no remote readiness is claimed. | + +## Handoff and standard review + +Prioritize false-positive removal in INC-QML-17/18/19 before broadening adoption. +Each increment must include production regressions, semantic diagnostics, unchanged +source-span/identity controls, stale-edge removal, failure retention, corrected +retry, consumer and installed-artifact evidence. Existing INC-QML-08/11/15 remain +open. Full-suite and language-convention gaps are upstream delivery gates rather +than invented product features. + +At the audit checkpoint, the [frozen fork policy](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/AGENTS.md) +emphasized truthful AI attribution and receiver/declaration identity +counterexamples and promotion of opt-in audit probes into normal regression +collection. The audit strengthens evidence obligations without claiming a runtime +fix, new infrastructure or complete Qt/QML coverage. + +Final local handoff checks: Ruff passes for all three audit probes (66, 139 and +221 physical lines); skillgen confirms 134 matching artifacts. Default discovery +collects none of the opt-in probes, as documented. A read-only document check +resolves 337 local links and 237 exact test references across 21 documents, with +all 84 acceptance IDs assigned. No reviewed private project, machine path or +credential patterns remain; existing public fork evidence links are retained. +Whitespace checks pass. The final graph refresh after probe additions contains +20,499 nodes, 43,081 edges and 1,045 communities; counts are navigation state, +not acceptance evidence. Missing unrelated optional grammars and the intentional +Luau partial fixture remain explicit baseline limitations. No model/API labeling +was invoked. + +## Correction disposition and exit review + +| Finding/profile | Current disposition | +| --- | --- | +| A10/A11 receiver and descendant ownership | INC-QML-17 ordinary source/lifecycle and installed-wheel cases pass; accepted native construction ancestry is completed with INC-QML-19 | +| A12 declaration/provider identity | INC-QML-18 exact lexical engine/provider cases pass; INC-QML-20 verifies component loadUrl engine association | +| A13 lexical native aliases | INC-QML-19 ordinary endpoint/registration/lifecycle and installed-wheel cases pass; imported-header targets remain conservatively unavailable | +| Literal engine-constructor and component loadUrl omissions | INC-QML-20 ordinary provenance/provider/lifecycle cases pass within the documented literal profile | + +### A14 — Qualified classes collide in a single source file + +A global Base and Public::Base share a canonical generic C++ class ID in one +accepted file. Native construction retains ambiguity instead of fabricating an +identity. INC-QML-21 owns the producer correction under REQ-QML-008-AC02, +REQ-QML-016-AC01/AC04 and REQ-QML-017-AC02/AC04. Separate-file positive controls +do not prove the colliding form works. + +### A15 — P1: Shadowed static reflection API names lend SDK semantics + +Aliases or source-defined global/namespace QMetaObject and QQmlProperty classes +still authorize invoke/read/write/property-handle targets as though they were +SDK APIs. The receiver is correctly owned, but API identity is unproved. The +excluded opt-in `tests/audit/probe_qt_reflection_type_shadowing.py` exercises +production build and directed/undirected reload: twelve rejection assertions fail +and two unshadowed controls pass (2.27 seconds). No malformed-span, provenance or +direction assertion fails before the false-target assertion. + +INC-QML-22 owns this correction under REQ-QML-017-AC02/AC04 and must promote the +probe into normal regression collection. A failing/excluded probe is findings +evidence, not release acceptance. Ordinary discovery excludes audit probes. + +### A16 — P1: Read-only Windows replacement can clobber before failure + +`tests/test_atomic_writes.py::test_write_text_atomic_refuses_a_readonly_destination_without_leaking_a_temp` +fails on both the original `1128205` baseline and the correction source. The +shared writer's fallback renames the read-only destination aside, publishes new +bytes, then raises during displaced-file cleanup. Reported failure no longer +means the prior destination survived. INC-QML-23 owns this pre-existing shared +persistence correction under REQ-QML-018-AC06. Loader tests' forced replacement +failure retention remains valid for its narrower boundary. + +The full contribution gate also exposes unavailable Windows fixtures/dependencies +and unchanged platform expectations. Exact baseline comparison and current +correction evidence are recorded in [validation](VALIDATION.md); no gate is +weakened to treat those results as successful. + +## Correction exit findings A17–A20 + +| Finding | Reproduction and disposition | +| --- | --- | +| A17 — SDK-name forward declarations authorize loaders | Eight source-forward controls fail while eight external SDK controls pass. INC-QML-24 applies source declaration authority at constructors, loaders, engine associations and URL wrappers. | +| A18 — Product completion does not govern success | Sixteen initial real/injected publication caller assertions fail. INC-QML-25 prepares and commits the accepted cohort together; rollback and cleanup have truthful independent diagnostics. Peer review also exposed a non-OS second restoration fault that discarded snapshots; its correction retains recovery evidence. | +| A19 — Watch keeps orphan references after source restoration | Four reflection lifecycle assertions fail complete normalized parity; seven orphan-cleanup reproductions fail while five ownership controls pass. INC-QML-26 receives successful complete-refresh authority and preserves live/semantic/hyperedge references. | +| A20 — Native property handler omits custom NOTIFY | Eight of nineteen initial native handler cases fail accepted subscription assertions. INC-QML-27 maps the property's accepted accessor; adjacent explicit-handler parameter tests expose and correct improper signal-name injection. | + +A14's qualified identity correction additionally rejects namespace-list overflow: +the two-namespace ambiguity control passes while a 51-namespace case fails before +the guard correction. These are concrete acceptance corrections, not claims that +every Qt runtime mechanism is modeled. Final validation and plan review record +implemented cases, artifact evidence and remaining broader scope separately. + +## Adoption correction closure + +The baseline audit's typed-factory exclusion probe records the old supported +profile; it is not current acceptance for the subsequently admitted bounded +declared provider form. INC-QML-11/15/08 and INC-QML-28–38 now have passing local +source/installed correction evidence, with ordinary collected positive and +rejection regressions replacing that old expectation. Preserve opt-in probes as +revision-specific findings, not release tests. The final profile, remaining +hosted/system gaps and baseline full-suite/type exceptions are recorded in +[validation](VALIDATION.md#final-adoption-delivery) and +[traceability](../../tests/TRACEABILITY.md#final-adoption-delivery). diff --git a/docs/qt-qml/IDENTIFIERS.md b/docs/qt-qml/IDENTIFIERS.md new file mode 100644 index 0000000000..59610671ef --- /dev/null +++ b/docs/qt-qml/IDENTIFIERS.md @@ -0,0 +1,208 @@ +# Qt/QML document identifiers + +Requirements and delivery increments use distinct namespaces: + +- Requirements use `REQ-QML-NNN`, for example `REQ-QML-018`. +- Acceptance criteria use `REQ-QML-NNN-ACNN`, for example `REQ-QML-018-AC02`. +- Increments use `INC-QML-NN`; work packages add a letter, for example `INC-QML-08a`. +- Architecture decisions retain their existing `D1` format. + +Distinct prefixes identify whether a reference describes required behavior or a +delivery stage. Numeric identities, criterion numbers, package suffixes, behavior +and evidence assignments remain stable. Do not renumber or reuse an identity. + +This is the canonical alias catalog. Every legacy identifier below permanently +refers to the same current identity. Use current IDs in new documents and source +annotations. Existing test names, comments, issues and evidence remain valid under +these mappings. Preserve recorded test names, generated artifacts, historical +logs, commit/branch names, URLs and license notices. + +Current internal links use current headings; existing heading links retain their +legacy targets. Navigation follows +[GitHub custom-anchor syntax](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#custom-anchors). + +Future requirements continue with `REQ-QML-022`; future top-level increments +continue with `INC-QML-51`. INC-QML-49 owns current-upstream integration; +INC-QML-50 owns followed Qt metadata source provenance. These counters describe different concerns and need +not advance together. Extend this catalog when retiring or migrating an ID; +never transfer an alias to unrelated behavior. Document identifiers do not change +runtime schemas or parser contracts. + +Shared assistant/filesystem/Terraform compatibility uses the independent +`REQ-CORE-NNN`, `REQ-CORE-NNN-ACNN` and `INC-CORE-NN` namespaces defined in the +[compatibility plan](../COMPATIBILITY.md). The Qt/QML counters and aliases above +remain unchanged; shared contribution-gate follow-up is not another Qt feature. + +The twenty-one requirements comprise eighty-five criteria. The initial seventeen +requirements/sixty-eight criteria and seven `REQ-QML-018` adoption criteria retain +separate support/evidence profiles. REQ-QML-019 adds four HTML-view criteria. +REQ-QML-020 adds three explicit-membership criteria locally Verified for the bounded +static source profile through INC-QML-14. Implementation and verification status belong +to [requirements](../REQUIREMENTS.md) and [traceability](../../tests/TRACEABILITY.md). +This catalog establishes identity, not implementation or verification. + +REQ-QML-021 adds three middle mouse navigation criteria. INC-QML-16 implements +that camera interaction and removes the optional Overview selection mode under +the revised REQ-QML-019-AC04. These identities describe the HTML consumer and do +not advance the Qt analysis policy or AST schema. + +[INC-QML-10](PLAN.md#inc-qml-10--native-qt-source-ownership-correction) corrects +native ownership under existing requirements and criteria. It adds no requirement +or acceptance ID; the catalog at that increment contained nineteen requirements/ +seventy-eight criteria. Architecture decision D13 records its authority boundary. Earlier +aliases and evidence names remain unchanged. +Accepted unchanged-context deduplication adds regression evidence, not an ID. +Current final-artifact status remains in requirements and traceability. + +[INC-QML-11](PLAN.md#inc-qml-11--inherited-qt-signal-endpoint-lookup) implements +bounded inherited-signal lookup under existing REQ-QML-016-AC01/AC04. It adds +no requirement or acceptance ID. Local source/update evidence and remaining +integration gaps belong to traceability. INC-QML-28 retains explicit-emission +admission under the same criteria. REQ-QML-018-AC07 adds the header-dispatch +criterion without renumbering earlier identities. + +[INC-QML-12](PLAN.md#inc-qml-12--out-of-line-constructor-canonical-ownership) +locally completes bounded singleton constructor parent correction under existing native ownership +criteria, separately from INC-QML-11. It adds no requirement/acceptance identity +and does not establish exact overload or inherited endpoint support. + +## Requirement aliases + +- QML-001 -> [REQ-QML-001](../REQUIREMENTS.md#req-qml-001--parser-availability-and-compatibility) +- QML-002 -> [REQ-QML-002](../REQUIREMENTS.md#req-qml-002--corpus-discovery) +- QML-003 -> [REQ-QML-003](../REQUIREMENTS.md#req-qml-003--source-backed-declarations-and-identity) +- QML-004 -> [REQ-QML-004](../REQUIREMENTS.md#req-qml-004--imports-and-module-resolution) +- QML-005 -> [REQ-QML-005](../REQUIREMENTS.md#req-qml-005--component-and-member-scopes) +- QML-006 -> [REQ-QML-006](../REQUIREMENTS.md#req-qml-006--bindings-and-property-aliases) +- QML-007 -> [REQ-QML-007](../REQUIREMENTS.md#req-qml-007--javascript-handlers-and-signals) +- QML-008 -> [REQ-QML-008](../REQUIREMENTS.md#req-qml-008--qtc-exposure-bridge) +- QML-009 -> [REQ-QML-009](../REQUIREMENTS.md#req-qml-009--qt-project-and-resource-metadata) +- QML-010 -> [REQ-QML-010](../REQUIREMENTS.md#req-qml-010--deterministic-graph-and-provenance) +- QML-011 -> [REQ-QML-011](../REQUIREMENTS.md#req-qml-011--incremental-correctness) +- QML-012 -> [REQ-QML-012](../REQUIREMENTS.md#req-qml-012--failure-handling-and-persistence) +- QML-013 -> [REQ-QML-013](../REQUIREMENTS.md#req-qml-013--user-facing-graph-consumers) +- QML-014 -> [REQ-QML-014](../REQUIREMENTS.md#req-qml-014--platform-and-compatibility-evidence) +- QML-015 -> [REQ-QML-015](../REQUIREMENTS.md#req-qml-015--documentation-and-upstream-delivery) +- QML-016 -> [REQ-QML-016](../REQUIREMENTS.md#req-qml-016--qt-c-signals-slots-and-connections) +- QML-017 -> [REQ-QML-017](../REQUIREMENTS.md#req-qml-017--bidirectional-qml-and-c-object-integration) +- QML-018 -> [REQ-QML-018](../REQUIREMENTS.md#req-qml-018--installed-project-adoption-hardening) + +## Acceptance criterion aliases + +All mappings preserve the requirement and acceptance numbers. Existing tests with +`qmlNNN_acNN` names remain evidence for the corresponding current criterion. + +- QML-001-AC01 -> [REQ-QML-001-AC01](../REQUIREMENTS.md#req-qml-001--parser-availability-and-compatibility) +- QML-001-AC02 -> [REQ-QML-001-AC02](../REQUIREMENTS.md#req-qml-001--parser-availability-and-compatibility) +- QML-001-AC03 -> [REQ-QML-001-AC03](../REQUIREMENTS.md#req-qml-001--parser-availability-and-compatibility) +- QML-001-AC04 -> [REQ-QML-001-AC04](../REQUIREMENTS.md#req-qml-001--parser-availability-and-compatibility) +- QML-002-AC01 -> [REQ-QML-002-AC01](../REQUIREMENTS.md#req-qml-002--corpus-discovery) +- QML-002-AC02 -> [REQ-QML-002-AC02](../REQUIREMENTS.md#req-qml-002--corpus-discovery) +- QML-002-AC03 -> [REQ-QML-002-AC03](../REQUIREMENTS.md#req-qml-002--corpus-discovery) +- QML-002-AC04 -> [REQ-QML-002-AC04](../REQUIREMENTS.md#req-qml-002--corpus-discovery) +- QML-003-AC01 -> [REQ-QML-003-AC01](../REQUIREMENTS.md#req-qml-003--source-backed-declarations-and-identity) +- QML-003-AC02 -> [REQ-QML-003-AC02](../REQUIREMENTS.md#req-qml-003--source-backed-declarations-and-identity) +- QML-003-AC03 -> [REQ-QML-003-AC03](../REQUIREMENTS.md#req-qml-003--source-backed-declarations-and-identity) +- QML-003-AC04 -> [REQ-QML-003-AC04](../REQUIREMENTS.md#req-qml-003--source-backed-declarations-and-identity) +- QML-004-AC01 -> [REQ-QML-004-AC01](../REQUIREMENTS.md#req-qml-004--imports-and-module-resolution) +- QML-004-AC02 -> [REQ-QML-004-AC02](../REQUIREMENTS.md#req-qml-004--imports-and-module-resolution) +- QML-004-AC03 -> [REQ-QML-004-AC03](../REQUIREMENTS.md#req-qml-004--imports-and-module-resolution) +- QML-004-AC04 -> [REQ-QML-004-AC04](../REQUIREMENTS.md#req-qml-004--imports-and-module-resolution) +- QML-005-AC01 -> [REQ-QML-005-AC01](../REQUIREMENTS.md#req-qml-005--component-and-member-scopes) +- QML-005-AC02 -> [REQ-QML-005-AC02](../REQUIREMENTS.md#req-qml-005--component-and-member-scopes) +- QML-005-AC03 -> [REQ-QML-005-AC03](../REQUIREMENTS.md#req-qml-005--component-and-member-scopes) +- QML-005-AC04 -> [REQ-QML-005-AC04](../REQUIREMENTS.md#req-qml-005--component-and-member-scopes) +- QML-006-AC01 -> [REQ-QML-006-AC01](../REQUIREMENTS.md#req-qml-006--bindings-and-property-aliases) +- QML-006-AC02 -> [REQ-QML-006-AC02](../REQUIREMENTS.md#req-qml-006--bindings-and-property-aliases) +- QML-006-AC03 -> [REQ-QML-006-AC03](../REQUIREMENTS.md#req-qml-006--bindings-and-property-aliases) +- QML-006-AC04 -> [REQ-QML-006-AC04](../REQUIREMENTS.md#req-qml-006--bindings-and-property-aliases) +- QML-007-AC01 -> [REQ-QML-007-AC01](../REQUIREMENTS.md#req-qml-007--javascript-handlers-and-signals) +- QML-007-AC02 -> [REQ-QML-007-AC02](../REQUIREMENTS.md#req-qml-007--javascript-handlers-and-signals) +- QML-007-AC03 -> [REQ-QML-007-AC03](../REQUIREMENTS.md#req-qml-007--javascript-handlers-and-signals) +- QML-007-AC04 -> [REQ-QML-007-AC04](../REQUIREMENTS.md#req-qml-007--javascript-handlers-and-signals) +- QML-008-AC01 -> [REQ-QML-008-AC01](../REQUIREMENTS.md#req-qml-008--qtc-exposure-bridge) +- QML-008-AC02 -> [REQ-QML-008-AC02](../REQUIREMENTS.md#req-qml-008--qtc-exposure-bridge) +- QML-008-AC03 -> [REQ-QML-008-AC03](../REQUIREMENTS.md#req-qml-008--qtc-exposure-bridge) +- QML-008-AC04 -> [REQ-QML-008-AC04](../REQUIREMENTS.md#req-qml-008--qtc-exposure-bridge) +- QML-009-AC01 -> [REQ-QML-009-AC01](../REQUIREMENTS.md#req-qml-009--qt-project-and-resource-metadata) +- QML-009-AC02 -> [REQ-QML-009-AC02](../REQUIREMENTS.md#req-qml-009--qt-project-and-resource-metadata) +- QML-009-AC03 -> [REQ-QML-009-AC03](../REQUIREMENTS.md#req-qml-009--qt-project-and-resource-metadata) +- QML-009-AC04 -> [REQ-QML-009-AC04](../REQUIREMENTS.md#req-qml-009--qt-project-and-resource-metadata) +- QML-010-AC01 -> [REQ-QML-010-AC01](../REQUIREMENTS.md#req-qml-010--deterministic-graph-and-provenance) +- QML-010-AC02 -> [REQ-QML-010-AC02](../REQUIREMENTS.md#req-qml-010--deterministic-graph-and-provenance) +- QML-010-AC03 -> [REQ-QML-010-AC03](../REQUIREMENTS.md#req-qml-010--deterministic-graph-and-provenance) +- QML-010-AC04 -> [REQ-QML-010-AC04](../REQUIREMENTS.md#req-qml-010--deterministic-graph-and-provenance) +- QML-011-AC01 -> [REQ-QML-011-AC01](../REQUIREMENTS.md#req-qml-011--incremental-correctness) +- QML-011-AC02 -> [REQ-QML-011-AC02](../REQUIREMENTS.md#req-qml-011--incremental-correctness) +- QML-011-AC03 -> [REQ-QML-011-AC03](../REQUIREMENTS.md#req-qml-011--incremental-correctness) +- QML-011-AC04 -> [REQ-QML-011-AC04](../REQUIREMENTS.md#req-qml-011--incremental-correctness) +- QML-012-AC01 -> [REQ-QML-012-AC01](../REQUIREMENTS.md#req-qml-012--failure-handling-and-persistence) +- QML-012-AC02 -> [REQ-QML-012-AC02](../REQUIREMENTS.md#req-qml-012--failure-handling-and-persistence) +- QML-012-AC03 -> [REQ-QML-012-AC03](../REQUIREMENTS.md#req-qml-012--failure-handling-and-persistence) +- QML-012-AC04 -> [REQ-QML-012-AC04](../REQUIREMENTS.md#req-qml-012--failure-handling-and-persistence) +- QML-013-AC01 -> [REQ-QML-013-AC01](../REQUIREMENTS.md#req-qml-013--user-facing-graph-consumers) +- QML-013-AC02 -> [REQ-QML-013-AC02](../REQUIREMENTS.md#req-qml-013--user-facing-graph-consumers) +- QML-013-AC03 -> [REQ-QML-013-AC03](../REQUIREMENTS.md#req-qml-013--user-facing-graph-consumers) +- QML-013-AC04 -> [REQ-QML-013-AC04](../REQUIREMENTS.md#req-qml-013--user-facing-graph-consumers) +- QML-014-AC01 -> [REQ-QML-014-AC01](../REQUIREMENTS.md#req-qml-014--platform-and-compatibility-evidence) +- QML-014-AC02 -> [REQ-QML-014-AC02](../REQUIREMENTS.md#req-qml-014--platform-and-compatibility-evidence) +- QML-014-AC03 -> [REQ-QML-014-AC03](../REQUIREMENTS.md#req-qml-014--platform-and-compatibility-evidence) +- QML-014-AC04 -> [REQ-QML-014-AC04](../REQUIREMENTS.md#req-qml-014--platform-and-compatibility-evidence) +- QML-015-AC01 -> [REQ-QML-015-AC01](../REQUIREMENTS.md#req-qml-015--documentation-and-upstream-delivery) +- QML-015-AC02 -> [REQ-QML-015-AC02](../REQUIREMENTS.md#req-qml-015--documentation-and-upstream-delivery) +- QML-015-AC03 -> [REQ-QML-015-AC03](../REQUIREMENTS.md#req-qml-015--documentation-and-upstream-delivery) +- QML-015-AC04 -> [REQ-QML-015-AC04](../REQUIREMENTS.md#req-qml-015--documentation-and-upstream-delivery) +- QML-016-AC01 -> [REQ-QML-016-AC01](../REQUIREMENTS.md#req-qml-016--qt-c-signals-slots-and-connections) +- QML-016-AC02 -> [REQ-QML-016-AC02](../REQUIREMENTS.md#req-qml-016--qt-c-signals-slots-and-connections) +- QML-016-AC03 -> [REQ-QML-016-AC03](../REQUIREMENTS.md#req-qml-016--qt-c-signals-slots-and-connections) +- QML-016-AC04 -> [REQ-QML-016-AC04](../REQUIREMENTS.md#req-qml-016--qt-c-signals-slots-and-connections) +- QML-017-AC01 -> [REQ-QML-017-AC01](../REQUIREMENTS.md#req-qml-017--bidirectional-qml-and-c-object-integration) +- QML-017-AC02 -> [REQ-QML-017-AC02](../REQUIREMENTS.md#req-qml-017--bidirectional-qml-and-c-object-integration) +- QML-017-AC03 -> [REQ-QML-017-AC03](../REQUIREMENTS.md#req-qml-017--bidirectional-qml-and-c-object-integration) +- QML-017-AC04 -> [REQ-QML-017-AC04](../REQUIREMENTS.md#req-qml-017--bidirectional-qml-and-c-object-integration) +- QML-018-AC01 -> [REQ-QML-018-AC01](../REQUIREMENTS.md#req-qml-018--installed-project-adoption-hardening) +- QML-018-AC02 -> [REQ-QML-018-AC02](../REQUIREMENTS.md#req-qml-018--installed-project-adoption-hardening) +- QML-018-AC03 -> [REQ-QML-018-AC03](../REQUIREMENTS.md#req-qml-018--installed-project-adoption-hardening) +- QML-018-AC04 -> [REQ-QML-018-AC04](../REQUIREMENTS.md#req-qml-018--installed-project-adoption-hardening) +- QML-018-AC05 -> [REQ-QML-018-AC05](../REQUIREMENTS.md#req-qml-018--installed-project-adoption-hardening) +- QML-018-AC06 -> [REQ-QML-018-AC06](../REQUIREMENTS.md#req-qml-018--installed-project-adoption-hardening) + +## Increment and package aliases + +Packages retain their parent increment and suffix. Links lead to the owning +increment section; completion and dependencies remain in the [plan](PLAN.md). + +- QML-00 -> [INC-QML-00](PLAN.md#inc-qml-00--baseline-and-parser-decision) +- QML-01 -> [INC-QML-01](PLAN.md#inc-qml-01--discovery-and-minimal-declarations) +- QML-01a -> [INC-QML-01a](PLAN.md#inc-qml-01--discovery-and-minimal-declarations) +- QML-01b -> [INC-QML-01b](PLAN.md#inc-qml-01--discovery-and-minimal-declarations) +- QML-02 -> [INC-QML-02](PLAN.md#inc-qml-02--modules-and-component-scope) +- QML-02a -> [INC-QML-02a](PLAN.md#inc-qml-02--modules-and-component-scope) +- QML-02b -> [INC-QML-02b](PLAN.md#inc-qml-02--modules-and-component-scope) +- QML-02c -> [INC-QML-02c](PLAN.md#inc-qml-02--modules-and-component-scope) +- QML-03 -> [INC-QML-03](PLAN.md#inc-qml-03--bindings-aliases-javascript-and-signals) +- QML-03a -> [INC-QML-03a](PLAN.md#inc-qml-03--bindings-aliases-javascript-and-signals) +- QML-03b -> [INC-QML-03b](PLAN.md#inc-qml-03--bindings-aliases-javascript-and-signals) +- QML-03c -> [INC-QML-03c](PLAN.md#inc-qml-03--bindings-aliases-javascript-and-signals) +- QML-04 -> [INC-QML-04](PLAN.md#inc-qml-04--qtc-exposure-signals-and-access-to-qml) +- QML-04a -> [INC-QML-04a](PLAN.md#inc-qml-04--qtc-exposure-signals-and-access-to-qml) +- QML-04b -> [INC-QML-04b](PLAN.md#inc-qml-04--qtc-exposure-signals-and-access-to-qml) +- QML-04c -> [INC-QML-04c](PLAN.md#inc-qml-04--qtc-exposure-signals-and-access-to-qml) +- QML-05 -> [INC-QML-05](PLAN.md#inc-qml-05--build-module-and-resource-metadata) +- QML-06 -> [INC-QML-06](PLAN.md#inc-qml-06--incremental-updates-watch-and-caches) +- QML-06a -> [INC-QML-06a](PLAN.md#inc-qml-06--incremental-updates-watch-and-caches) +- QML-06b -> [INC-QML-06b](PLAN.md#inc-qml-06--incremental-updates-watch-and-caches) +- QML-07 -> [INC-QML-07](PLAN.md#inc-qml-07--consumers-support-matrix-and-upstream-delivery) +- QML-07a -> [INC-QML-07a](PLAN.md#inc-qml-07--consumers-support-matrix-and-upstream-delivery) +- QML-07b -> [INC-QML-07b](PLAN.md#inc-qml-07--consumers-support-matrix-and-upstream-delivery) +- QML-08 -> [INC-QML-08](PLAN.md#inc-qml-08--installed-project-adoption-hardening) +- QML-08a -> [INC-QML-08a](PLAN.md#inc-qml-08--installed-project-adoption-hardening) +- QML-08b -> [INC-QML-08b](PLAN.md#inc-qml-08--installed-project-adoption-hardening) +- QML-08c -> [INC-QML-08c](PLAN.md#inc-qml-08--installed-project-adoption-hardening) + +INC-QML-12 corrects generic constructor ownership and INC-QML-13 corrects source +containment and aggregate edge-count presentation under existing criteria. +INC-QML-14 locally completes REQ-QML-020 membership projection as a separate bounded +correction with production and reviewed installed-artifact evidence. D15 +distinguishes source membership from component use and Qt runtime behavior. +No earlier numeric identity is changed, retired or reused. diff --git a/docs/qt-qml/IMPLEMENTATION.md b/docs/qt-qml/IMPLEMENTATION.md new file mode 100644 index 0000000000..9d72ef3b92 --- /dev/null +++ b/docs/qt-qml/IMPLEMENTATION.md @@ -0,0 +1,626 @@ +# Executed increments + +## Upstream submission scope + +The [scoped upstream contribution](UPSTREAM_CONTRIBUTION.md) retains bounded Qt/QML analysis +and its source, incremental, publication and consumer contracts. REQ-QML-019 +and REQ-QML-021, D12/D16, and the generic viewer portions of INC-QML-09/13/16 +are deferred for this submission. Native Codex REQ-CORE-001-AC01/AC05 and the +broad fork repository policy are also deferred; the baseline installer and +repository policy apply. Constructor and source-containment work in INC-QML-13 +remains in scope. + +The [complete frozen fork](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4) +and its recorded 8b6c9d2 source/runner evidence retain their own revision scope. +Deferred feature sections and their exact tests below are historical evidence, +not implemented or verified contracts of this narrower contribution. A changed +contribution head requires fresh applicable checks; prior totals, skips and +hosted results do not establish those checks. + +The [follow-up audit](FOLLOWUP_AUDIT.md) records receiver, descendant, engine and +alias false positives at its inspected baseline. INC-QML-17–27 implement bounded +corrections; INC-QML-11/15/08 and INC-QML-28–38 complete the bounded local adoption +profile recorded below. Historical records retain their original revision and scope. +The [final adoption delivery](#final-adoption-delivery) preserves that older +checkpoint. Later integration scope and evidence are recorded in the +[current-upstream integration profile](../REQUIREMENTS.md#current-upstream-integration-profile) +and the [immutable 8b6c9d2 checkpoint](../../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2). +Those records retain their tested revision, declared profiles and remaining gaps; +they do not establish acceptance of a later contribution head. + + + +The increment records below retain revision-specific implementation and evidence. +Their epochs, pending statements and test totals describe their individual +checkpoints. + +## INC-QML-00 + +Complete for optional parser selection; see [parser evidence](PARSER_DECISION.md). +No production criterion closes from probes alone. + + + +## INC-QML-01 + +INC-QML-01a implements publication guards and conservative refresh. INC-QML-01b adds lazy +offline grammar loading, QML admission, declarations/imports, explicit roots, +scoped portable IDs, spans, safe failures and a pinned optional qml extra. +Bounded base64 companions preserve semantic values through HTML sanitation. +Empty editor files have file facts and an explicit informational diagnostic. +The subset includes objects, grouped properties, properties/modifiers, signals, +functions, inline components, enums, pragmas and imports. Module joins are INC-QML-02; +bindings/calls/signals INC-QML-03. C++/CMake/qmake/resources remain later increments. + +Windows focused checks: 90 passed, zero skipped. Real spawn workers, portable +identity, build/JSON reload, forced/equal-count/edge-loss retention, read races +and successful retries are exercised. New modules pass Ruff/Pyright. Clean wheel +installation and remaining parser-failure cases are being verified. + +| Criterion | Status | Evidence / remaining gate | +| --- | --- | --- | +| REQ-QML-001-AC01 | Verified for Windows x64 | Wheel from committed source installs and runs in isolated Python 3.10/3.12/3.13/3.14 environments; tests/qml_installed_smoke.py | +| REQ-QML-001-AC02 | Verified in INC-QML-03 | Hand-checked production syntax and original spans; fresh offline process; test_qml_syntax_profile.py | +| REQ-QML-001-AC03 | Verified | Actual optional import rejection, incompatible binding/native parse failures, safe cache bypass | +| REQ-QML-001-AC04 | Verified | Empty, malformed and grammar-recognized unsupported fixtures have distinct diagnostics | +| REQ-QML-003-AC01 | Verified | Exact declarations/ownership/types in test_qml_declarations.py | +| REQ-QML-003-AC02 | Verified | Duplicate scopes/paths/stems/case in identity and graph tests | +| REQ-QML-003-AC03 | Verified | Comment/string/grouped-property negatives | +| REQ-QML-003-AC04 | Verified | Relocation, inserted comments, reordered/warm batches and actual spawn | + +REQ-QML-003 is Verified for this subset. REQ-QML-001/REQ-QML-002/REQ-QML-010/REQ-QML-011/REQ-QML-012/REQ-QML-013/REQ-QML-014/REQ-QML-015 are Partially +implemented, with later metadata/update/platform/consumer gates still open. +Other requirements remain Planned. + +## Increment review and module debt + +INC-QML-00 added INC-QML-01a/INC-QML-01b. INC-QML-01 review requires semantic qmldir validation, +immutable indexes, provider-only refresh and independent reference/event sites +in INC-QML-02/INC-QML-03. No new top-level increment is needed. Cache optimization is INC-QML-06. + +Root owns narrow integration hooks in oversized legacy extract.py, detect.py, +build.py, cli.py and watch.py. New responsibilities remain in modules below 300 +lines. Exception: integration hooks only in these existing files. Exit: upstream +coordinated facade/writer splitting, outside this increment. Final validation +records measured sizes/deltas before handoff. + +INC-QML-01 completion review: clean built-wheel checks pass in all four declared +Windows lanes with Python-level network/process denial and no Qt SDK. Parser +absence/ABI/native-error and unsupported-source regressions also pass. Production +parser corpus AC02 remains assigned to INC-QML-03. Linux/macOS stay unverified. +INC-QML-01 is complete for this declared optional profile; advance to INC-QML-02. + + + +## INC-QML-02 + +Implemented immutable per-run module/member indexes, explicit-root URI providers, +directory imports, aliases, observed versions, singleton/inline/internal visibility, +owned resolution sites, exact-name qmldir admission and semantic metadata parsing. +Unknown framework types/versions remain unresolved. Module import grants visibility; +packaging depends does not. No corpus expansion, plugin execution or SDK traversal. + +Observed export versions establish module availability in this initial profile. +Requested unobserved module minors remain unresolved pending INC-QML-05 metadata. +Runtime parent/outer context remain unavailable without supported explicit evidence. + +INC-QML-02a/INC-QML-02b/INC-QML-02c use digest scopes and direct-producer AST markers. +Regressions cover growing scope keys and missing producer provenance. Raw semantic +metadata survives sanitation. The repository graph excludes its two deliberately +malformed parser fixtures via .graphifyignore; tests still open them directly. +Integrated admission/update checks pass: 44 passed, two symlink cases skipped +because the Windows account lacks symlink creation privilege. Those skipped +platform cases remain open under REQ-QML-002/REQ-QML-014. Real >20-file pool execution, +borrowed-context immutability, named ignores/root admission and metadata-only +update parity passed. Raw update lost orientation on reload until QML edges also +carried Graphify's `_src`/`_tgt` markers; the strict parity regression now passes. +All new production modules pass Ruff/Pyright. INC-QML-02 is complete for this profile. + +| Criterion | Status | Actual test in test_qml_resolution.py or test_qml_scope.py | +| --- | --- | --- | +| REQ-QML-004-AC01 | Verified | test_aliased_directory_and_uri_imports_do_not_cross_bind; test_version_availability_and_latest_compatible_export | +| REQ-QML-004-AC02 | Verified | test_aliased_directory_and_uri_imports_do_not_cross_bind; test_versioned_layout_and_missing_version_evidence | +| REQ-QML-004-AC03 | Verified | test_competing_providers_and_missing_modules_have_no_target_edges | +| REQ-QML-004-AC04 | Verified | test_declared_roots_and_remote_import_never_expand_corpus; test_directory_and_script_projection_and_ignored_disk_provider | +| REQ-QML-005-AC01 | Verified | test_component_ids_do_not_leak_between_files_or_inline_components | +| REQ-QML-005-AC02 | Verified | test_inline_shadow_and_inherited_members_are_distinct_roles; test_singleton_pragma_and_qualified_access | +| REQ-QML-005-AC03 | Verified | test_internal_external_dynamic_and_lexical_members_stay_unresolved; bounded inheritance cycle test | +| REQ-QML-005-AC04 | Verified | test_module_script_exports_have_separate_lookup_roles; inline shadow test | + +INC-QML-03 owns lexical/script/handler gates; INC-QML-06 owns cache optimization. +No additional top-level increment is required. INC-QML-02c covers the newly found +ignore/provenance/direction defects. INC-QML-04/INC-QML-05 retain C++ and project metadata work. + + + +## INC-QML-03 + +Complete for the declared Windows x64 static profile, including Qt 6.5/6.8 source +fixtures. INC-QML-03a adds objects held in properties/bindings/arrays and conservative +template scope barriers. INC-QML-03b adds source-owned binding, read, alias, call and +handler facts with lexical shadowing and explicit unresolved/dynamic reasons. +INC-QML-03c adds accepted `.js`/`.mjs` overlays, classic Qt directives, explicit callable +ESM exports and script dependencies without changing the generic JavaScript graph. + +Subscriptions use references, emissions use uses, and proven callable invocations +use calls. Each occurrence owns its original span and a separate identity, retaining +repeated dependencies through the simple graph. Named declaration identity remains +stable after inserted comments; derived occurrence identities intentionally include +their source span. Extracted syntax and inferred endpoints remain separate. + +All four REQ-QML-006 and REQ-QML-007 criteria pass the exact tests listed individually in +[traceability](../../tests/TRACEABILITY.md). REQ-QML-001-AC02 closes against the shipping +adapter and the hand-checked syntax corpus. Explicit runtime target/context lookup, +ESM reexports, imported value exports and unavailable framework signals remain +unresolved. Name-based Component/delegate/sourceComponent barriers conservatively +prevent leakage; they do not prove Qt runtime creation context or framework types. + +Review caught malformed literal transport, capped lexical display lists, duplicate +held Connections, ignored mixed handler styles, inherited signal binders, generic-JS +name pollution and script import endpoints omitted by generic cross-family guards. +All have production regressions. Default-undirected export/reload now restores QML +edge direction from serialized endpoints. A final reader review corrected BOM and +CRLF normalization of qmldir byte offsets using bounded original binary reads. + +Windows source suite before the eight final reader regressions: 239 passed and two +host symlink-permission skips in each Python 3.10/3.12/3.13/3.14 lane. Clean optional +wheels run bindings/script/subscription production smoke; actual core-only install +retains Python extraction and emits missing-parser diagnostics. Final reader/wheel +reverification is recorded in VALIDATION.md. Linux/macOS remain pending hosted CI. + +INC-QML-06a/INC-QML-06b cover mutation/config/cache contracts; INC-QML-07a/INC-QML-07b cover +consumer direction and hosted installation/upstream evidence. Existing INC-QML-00..INC-QML-07 +and INC-QML-04a/INC-QML-04b/INC-QML-04c identities remain unchanged. +INC-QML-04, INC-QML-05 and INC-QML-06 implementation evidence follows separately. + + + +## INC-QML-03 measured modularity exceptions + +All new handwritten production and test modules stay below the 300-line default. +The integration owner permits only focused hooks in oversized upstream files: + +| Legacy file | Upstream lines | INC-QML-03 measured ceiling | Reason | +| --- | --- | --- | --- | +| graphify/extract.py | 8943 | 8997 | Admission/worker and scoped-overlay dispatch | +| graphify/detect.py | 2793 | 2797 | QML and exact named metadata admission | +| graphify/build.py | 2462 | 2466 | Narrow proof-based script projection | +| graphify/cli.py | 4915 | 4936 | Publication safety and conservative refresh | +| graphify/watch.py | 2488 | 2506 | Same safety/root/admission contracts | +| graphify/paths.py | 523 | 536 | Retain QML orientation on undirected reload | +| tests/test_extract.py | 4823 | 4827 | Preserve extension-only oracle plus exact metadata admission | + +Owner: Qt/QML integration maintainer. Exit: upstream coordinated facade/writer +splitting; domain analysis remains in focused modules. Later hooks must remeasure +these ceilings and retain this rationale rather than quietly extending them. + + + + +## INC-QML-04 + +Complete for the declared Qt6 static source profile verified on Windows Python +3.12.14; exact later-host proof remains a release obligation. Known annotations +are normalized before generic C++ extraction, preserving original bytes/spans and +C++/CLI/test-macro behavior. Separate Qt facts borrow final canonical C++ endpoints. +Registration, property/accessor/notify, invokable, signal/slot and source event +mechanisms retain their evidence. Q_OBJECT alone is never QML exposure. + +Member pointers, overload selectors/casts, ordinary compatible members, private +legacy slots, signal-to-signal, lambdas/functors/free functions and supported +connection/disconnect forms have actual facade regressions. No event projection +invents runtime delivery or immediate receiver calls. Literal QQmlApplicationEngine, +QQmlComponent and QQuickView source access, root/handle/objectName provenance, +QQmlProperty and reflective member intent are covered. Context/initial providers +remain scoped to an exact loaded component with local/lexical/duplicate rejection. + +Qt metadata readers/indexes ship as an internal dependency foundation for literal +source-file access. Public project/resource admission and their resolved module +bridges close in INC-QML-05. Revised native members, foreign/extended/attached/value +providers, arbitrary compiler conversions, dynamic creation and inline temporary +QQmlProperty expressions remain explicit unsupported results. + +The canonical requirements and individual test mappings advance REQ-QML-008/REQ-QML-016/REQ-QML-017; +REQ-QML-016-AC01..AC03 pass their native source cases. Their final incremental and +consumer criteria remain open. No new top-level increment is needed: INC-QML-06a/INC-QML-06b +retain mutation/configuration proof and INC-QML-07a/INC-QML-07b retain consumers/hosted release. + +All new handwritten source/test files remain below 300 physical lines. Updated +legacy hooks have the same integration owner and coordinated upstream-splitting +exit as the prior exception record. INC-QML-04 measured ceilings are: + +| Legacy file | Measured/permitted lines | Focused integration reason | +| --- | --- | --- | +| graphify/extract.py | 8982 | Canonical C++ normalization followed by the separate Qt join owner | +| graphify/build.py | 2469 | Exact cross-language endpoint proof guard | +| graphify/cache.py | 1782 | Syntax schema migration to version 5 | +| graphify/paths.py | 537 | Restore versioned Qt relationship direction | + +Domain logic remains in focused Qt modules. These exceptions do not permit +unmeasured later growth; each later increment remeasures its touched hooks. + + + + +## INC-QML-05 + +Complete for the literal Qt6 source profile. Discovery/registry/facade/worker +paths admit metadata without running CMake, qmake, moc, plugins or QML. One per-run +QtProjectIndex supplies accepted module/source/resource membership to native and +QML joins. Generated tooling descriptions retain separate origins and warnings. +Duplicate source declarations survive generic namespace canonicalization, so +multiple modules/aliases cannot silently become a unique provider. Unknown CMake +resource policy, dynamic build expressions and locale-dependent resource selection +remain explicit unsupported results. Final configuration/update parity is INC-QML-06. + +Legacy measured ceilings: extract.py 8999, detect.py 2797, test_extract.py 4827; +owner Qt integration maintainer (discovery owner source discovery maintainer). +Reason: narrow named-source admission, root-sensitive dispatch and exact fact +identity protection. Exit: coordinated upstream facade/classification extraction. + + + + +## INC-QML-06 + +Implemented accepted-corpus refresh and immutable worker cache policy. Native +syntax is reparsed in Qt contexts; ordinary C++ portable cache behavior remains. +Generic JavaScript already bypasses its syntax cache. Versioned Qt/QML metadata +is never reused as a project-resolved syntax result. .qt_analysis.json stores a +bounded hash-only parser/fact/policy/root/ignore/corpus checkpoint, committed after +successful graph and manifest writes. Failed source/configuration analysis keeps +prior graph, manifest and checkpoint bytes. Ordered GRAPHIFY_QML_IMPORT_ROOTS is +a project-relative JSON list; it changes lookup order, never corpus admission. + +Actual extract/update/watch tests cover metadata-only/native-only changes, ignored +providers, rename/delete/duplicate resources and modules, import-root order, parser +version, last Qt deletion, unchanged reruns and forced malformed-input rejection. +Focused worker/native cache and root-boundary evidence is recorded in validation. + +INC-QML-06 legacy ceilings: extract.py 9009, cli.py 4949, watch.py 2527. Owner Qt integration maintainer; focused policy/root/publication hooks only. Exit coordinated upstream facade/writer extraction. New handwritten modules/tests remain below300 lines. + + + +## INC-QML-07 + +Implemented the bounded static profile through real query/explain/path/affected, +ordinary graph HTML, call-flow HTML, source coverage reports and optional HTTP/ +stdio MCP consumers. Exact graph IDs remain opaque endpoint identities. Search +indexes curated decoded names/types/roles/module URIs and source mechanisms, +preserving user attributes and rejecting malformed transport before decoding. +CLI and MCP render identical selected source facts and path provenance. + +Affected propagation follows proven source ownership for versioned Qt/QML +dependency occurrences. Canonical header declarations can own implementation +sites only with their accepted definition file, callable identity and exact +extracted endpoint/span evidence. Signal delivery stays separate from calls. +Call-flow HTML preserves resolved and unresolved event/access evidence, and +ordinary HTML preserves semantic payloads and safely renders literal values. +Community aggregation states its omission of individual source facts. + +Canonical JSON retains both graph orientations; directed GraphML retains nested +metadata in JSON properties. Qt Cypher and actual Neo4j/FalkorDB SDK boundaries +preserve source direction, complete metadata and distinct relationship keys. +Reserved transport-property collisions fail before file publication or SDK +connection. Live database engine execution is unverified and is outside this +static profile. Presentation-format omissions are documented in EXPORT_MATRIX.md. +Report source-site status counts expose unresolved/error coverage independently +of graph edge confidence; source analysis never claims runtime verification. + +Six authoritative assistant fragments and all 134 generated artifacts/expected +outputs were updated together. Rendered AST examples use the trusted root, +ordered import paths and native refresh policy, and gate before publication. +The installed-artifact smoke now includes native QML_ELEMENT, literal CMake +membership and QRC loader resolution without executing corpus or a Qt SDK. + +INC-QML-07 retains the seventeen requirements and sixty-eight acceptance IDs. +Production normal-QML-edit/update/watch and no-change parity verify the existing +REQ-QML-011 criteria. No additional top-level increment is needed +for the agreed profile; computed/runtime/framework/plugin behavior remains an +explicit unsupported boundary. Final hosted verification is recorded in +VALIDATION.md and the delivery PR, not inferred from local tests or earlier runs. + + + +### INC-QML-07 measured modularity exceptions + +Thin consumer hooks stay in their existing upstream owners; domain policy lives +in focused qt_qml_search, qt_affected, qt_relationship_views, qt_html, qt_coverage, +qt_export and path_provenance modules. New handwritten modules/tests are below +300 physical lines. Current ceilings for modified legacy owners are: + +| Owner | Physical line ceiling | Reason | +| --- | --- | --- | +| graphify/affected.py | 329 | Source-owned occurrence dependency hook | +| graphify/callflow_html.py | 2058 | Typed event table and call-list exclusion hooks | +| graphify/cli.py | 4955 | Exact endpoint and shared path provenance hooks | +| graphify/serve.py | 2725 | Shared search, deterministic facts and MCP path hooks | +| graphify/export.py | 1369 | Atomic Qt-aware Cypher dispatch | +| graphify/exporters/html.py | 690 | Qt payload/detail and aggregation omission hooks | +| graphify/report.py | 397 | Source coverage view hook | +| tools/skillgen/gen.py | 1450 | Exact sanctioned source predicates under existing frozen validators | + +Owner: Qt integration maintainer, coordinating generic consumer, presentation, +export and generator boundaries with their upstream maintainers. Exit: an +upstream-coordinated extraction of shared consumer/writer/validator interfaces, +retaining existing regression characterization and frozen guidance contracts. +This exception does not authorize arbitrary growth or mechanical file slicing. + +## INC-QML-08a native source compatibility + +The bounded native syntax slice of REQ-QML-018-AC02 is locally implemented and +verified. Parser-only compatibility normalization accepts empty-brace parameter +defaults and `Q_UNUSED` statements without a caller semicolon. Its arguments +retain evaluated calls rather than being erased. C++ numeric digit separators +remain part of numeric tokens before quote masking, preserving later signal and +member ownership. Original source bytes remain authoritative for identities and +spans, including BOM, CRLF and Unicode; malformed/incomplete controls still fail +closed. Analysis does not execute corpus code or weaken publication guards. + +AST cache schema 6 retires earlier syntax records even at the same package +version. Qt policy epoch 2 invalidates an earlier unchanged-source analysis stamp. +Production regressions cover actual CLI refresh, prior graph/manifest/state/cache +retention on failure and ordinary-C++ warm-cache preservation. The local focused, +broad and installed-artifact evidence is recorded in +[validation](VALIDATION.md#inc-qml-08a-native-source-compatibility). + +The new `graphify/extractors/qt_cpp_compat.py` is 142 physical lines; +`graphify/extractors/qt_cpp_syntax.py` is 234. The existing cache owner remains +1782 lines without growth. Its focused schema hook has a 1782-line ceiling, owned +by the Qt integration maintainer, with upstream-coordinated behavior-preserving +extraction of the source-cache interface as its exit condition. + +At the native-syntax checkpoint, INC-QML-08 and REQ-QML-018 remained incomplete. Header-classification assessment, +bounded qmake extensions, typed factory/member providers, child-service signal +chains and combined whole-project/safe-subroot adoption stay within the remaining +INC-QML-08a/08b/08c work. Header discovery needs its own agreed requirement before +implementation; direct parser success does not prove production admission. Local +native-slice proof and an accepted scoped installed result do not establish these +additional capabilities. New reviewed-head hosted lanes have not been executed +for this source change; prior INC-QML-07 proof remains revision-specific. + +## INC-QML-09 community presentation + +Upstream disposition: this generic viewer increment and REQ-QML-019 are deferred. +The implementation, tests, counts and owner measurements below describe the +frozen complete fork. The narrowed contribution retains baseline generic HTML +behavior and separate Qt payload/tooltip/omission contracts. + +HTML community recovery validates complete partition membership and uses the +existing clustering and hub-labeling interfaces on an export-local view. A +custom graph's analysis is loaded beside that graph. Missing names become usable +local labels; stale partition names do not migrate to newly computed groups. +Failed or skipped publication retains prior output and reports its true CLI +outcome. The atomic writer and canonical graph owners remain unchanged. + +The viewer contract at this historical checkpoint starts with Select All checked and admits all +exported view nodes and edges before network construction. Large graphs retain +their complete labeled aggregate and supported cap. Overview optionally selects +the ten largest source communities with stable member-count/ID ordering and +unchecks Select All. Filters, search and all/none controls retain their roles. +All exported raw semantic metadata remains available. No saved-view state, +model request or analyzed-project execution is introduced. AC01–AC04 have local +exporter/CLI and emitted-script proof, with the reviewed installed artifact +checked. The focused selection passes 157 cases without skips; built-artifact +checks pass three cases. Prior selection-policy tests remain historical evidence +in VALIDATION. Browser visual, other-platform and new hosted checks are unexecuted. + +Historical handwritten owners measure [frozen html_communities owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/exporters/html_communities.py) (79 physical lines), +[frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (293) and [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (234). +Touched legacy ceilings are exporters/html.py (763) and cli.py (4966). +Owner: presentation maintainer, coordinating CLI/export upstream ownership. +The HTML module keeps one inline viewer state across layout, filtering, search +and node details; the CLI retains only adjacent-path and outcome hooks. +Exit: a characterized, upstream-coordinated extraction of the cohesive viewer +state into its own frontend resource, preserving emitted payload, controls, +escaping and packaging. This exception permits the measured cohesive correction, +not arbitrary growth or speculative layers. + +## INC-QML-10 native source ownership + +**Locally verified source/context correction and reviewed installed artifact.** This correction uses existing +REQ-QML-008-AC02, REQ-QML-016-AC01/AC04 and REQ-QML-017-AC02/AC04. The initial +seventeen-requirement/sixty-eight-criterion evidence remains the original bounded +profile, not proof of the newly reproduced ownership cases. + +Forward declarations retain their canonical facts/IDs without competing +with a unique complete class definition for out-of-line member/event ownership. +AST body presence supplies additive `is_definition` authority through the Qt +class overlay. Mapping also needs exact accepted `definition_file` and +`definition_location` with callable/class ownership; true duplicate complete +definitions or unsupported evidence remain unresolved. The focused owners are +`qt_cpp_mapping.py`, `qt_cpp_exposure.py` and `qt_event_index.py`. + +Qt policy epoch 3 refreshes earlier same-package analysis facts; AST cache schema +6 stays unchanged. The current context/ownership/upgrade selection passes 88 +cases; the final broad suite passes 954 with seven documented skips and one +existing warning. Earlier 78-focused/944-broad results describe the intermediate revision +before accepted-context body provenance was repaired. +Actual same-package CLI refresh and failure-retention controls use prior persisted +products; the reviewed wheel is built and installed at version 0.9.74 with 152 +Python payloads matching source/wheel/installation. No source identity is removed, +generic C++ ownership rewritten or connection inferred merely because a node is +isolated. Remaining INC-QML-08 source/metadata/provider gaps and D12 HTML +presentation policy are unchanged. See [D13](ARCHITECTURE.md#d13--complete-definitions-and-exact-provenance-authorize-native-ownership) +and the [executed validation](VALIDATION.md#inc-qml-10-native-source-ownership). + +Measured production owners are `qt_cpp_mapping.py` 233 physical lines, +`qt_cpp_exposure.py` 134, `qt_event_index.py` 111, `qt_qml_bridge.py` 110 and +`qt_incremental.py` 114. New `test_qt_cpp_definition_ownership.py` is 261 lines +with 18 cases; `test_qt_cpp_owner_upgrade.py` is 91 lines with four cases. All +remain below the 300-line handwritten-file ceiling. + +`enrich_qt_cpp` preserves exact producer source file/span when borrowing unchanged +complete-class context. Repeated fresh/context representations of one body now +deduplicate consistently; distinct body locations remain ambiguous. Borrowed +dictionaries and canonical class/member/emission IDs remain unchanged. New +`test_qt_cpp_context_ownership.py` has ten production direct/pipeline/build/reload +cases and 145 physical lines. All ten pass, including equivalent paths and +distinct-body/legacy/missing-evidence controls. The final rebuilt artifact's 152 +Python payloads match reviewed source and installation; final expanded broad +proof passes 954 cases. This internal transport correction adds no persisted field or +policy epoch beyond the historical INC-QML-10 policy 3/schema 6 snapshot. + +At the INC-QML-10 checkpoint, INC-QML-11 remained planned. Recursive inheritance compatibility at that revision did not +extend member endpoint lookup beyond immediate bases; a declared grandparent +signal needs a separate bounded lookup correction and regression/installed proof. +This gap belongs to existing REQ-QML-016-AC01/AC04 and is not closed by ownership +counts or by forcing edges onto unsupported/isolated nodes. Browser, other-platform +and new hosted evidence remains separate from the local proof recorded here. + +## Constructor/source/view corrections (INC-QML-12/13) + +The generic C++ producer now records missing constructor prototypes and bounded +class/constructor source proof through a focused helper. Exact complete-class, +prototype, signature, scope and source containment establish supported singleton +constructor ownership before the existing canonical merge. Supported singleton IDs and +original source spans remain; collapsed overload/delegation cases, malformed +facts and conflicting scope/owner/signatures cannot gain native class authority. +A source-proven callable may still contain observed facts, and independent local +QML handles retain their own evidence. + +Ordinary utility-member and local-class occurrence facts now link to their +uniquely accepted source callables when no Qt class definition is admitted. +Class IDs/native roles remain unavailable; this is source containment. Source +facts are not hidden, merged by label or connected by guessed runtime calls. +Sites with unproved callable ownership instead retain exact containment by a +unique accepted source file. This file relationship leaves callable/class/target +identities and unresolved status unchanged. Rejected file-role/provenance evidence +cannot authorize containment. + +The full generic aggregate-inspector change remains historical under deferred +REQ-QML-019. Internal/external source-edge counts remain a REQ-QML-020 membership +prerequisite; distinct-community labels and isolate explanations are deferred. +Native constructor and source-containment changes remain retained. +The frozen aggregate HTML inspector reports +internal source edges, external source edges +and distinct connected communities. Closed connected groups are distinguished +from genuine isolates; counts use the canonical input graph, not duplicated raw +serialized occurrences. Existing Select All, source-node Degree and bounded +aggregate rendering remain intact. Pre-aggregated callers without original +source counts display unavailable counts. + +The INC-QML-12/13 artifact's AST schema 7 and policy 5 invalidate stale same-version +facts. Its local source and +reviewed installed-artifact verification passes; exact evidence belongs to +VALIDATION.md and tests/TRACEABILITY.md. +Browser/system appearance is a separate unexecuted gap. At this earlier checkpoint, INC-QML-11 inherited +endpoint lookup and INC-QML-08 metadata/provider adoption remained unfinished. +At the INC-QML-12/13 review, INC-QML-14/REQ-QML-020 was recorded as a planned +metadata-membership correction: indexes resolved accepted targets without general +declaration-to-file edges. That policy-5/schema-7 artifact remains historical +evidence for constructor/source/view corrections. INC-QML-14 is now locally +complete as recorded below; earlier source-owner and viewer-count results +do not complete it. +At the INC-QML-12/13 checkpoint, INC-QML-15 retained the generic overloaded-constructor ID/location-parity +gap. Native source-file links passed their scoped parity checks; exact generic +overload identity was unverified at that revision. + +## Project/resource membership projection (INC-QML-14) + +Status: **Locally complete; Verified within the bounded static source profile**. +Production, lifecycle/consumer and reviewed installed-artifact evidence passes +under the existing REQ-QML-020-AC01–AC03. No requirement or acceptance identity is +added. [D15](ARCHITECTURE.md#d15--project-membership-is-independent-of-component-use) +separates observed package/resource membership from component use or Qt execution. + +`graphify/qt_project_membership.py::resolve_project_memberships` projects accepted +`qt_source` and `resource_alias` declarations into independent +`membership_resolution` sites. It consumes the accepted QtProjectIndex corpus and +explicit fresh AST identities; borrowed declarations/context remain read-only. +The helper returns fresh derived nodes/edges while replacing only its own stale +scratch sites/mechanisms. Identity-based pipeline publication prevents a replaced +borrowed site from shifting append offsets or leaking unrelated context facts. + +Each declaration contains its site with context `qt_membership_site`. A uniquely +resolved site references the actual canonical file/component with context +`qt_project_source` or `qt_resource_membership` and confidence `EXTRACTED`. Original +source spans, declaration identity, module/alias context and bounded status/reason/ +evidence remain on the site. Missing, duplicate, conditional, generated and unsafe +targets retain unresolved/unsupported attempts without a target edge. The helper +performs no source read, path expansion, build/QML/plugin execution or filename- +based endpoint reconstruction; raw facts and existing lookup behavior stay intact. + +The narrow `graphify/qt_qml_pipeline.py` hook runs after canonicalization in its +existing scratch join. Coverage uses `QML-RESOLVE-001`; invalid transport or an +unexpected failure reaches `QML_RESOLUTION_FAILED` and the existing publication +guard. No parser or writer is added. Qt policy epoch 6 refreshes earlier +same-version graphs; AST cache schema stays 7 and existing graph/manifest/root/ +analysis-state sequencing owns retention, corrected retry and idempotence. + +The helper/source-test/pipeline/lifecycle-test owners measure 187/283/85/267 +physical lines, all below the 300-line handwritten-file ceiling. The helper API +accepts optional `fresh_ast_ids=()` and returns `(derived_nodes, derived_edges)`. +Before the two additional graph-direction variants, the new source/lifecycle +suites pass 37 focused cases in 8.77 seconds and contribute those cases to the +reviewed-wheel broad selection: +**1081 passed / 7 skipped / 1 existing warning** in 135.53 seconds. A separate +cross-language/C# compatibility selection passes 97 cases in 4.05 seconds. +The later focused direction expansion passes 39 cases in 9.50 seconds, including +explicit CMake/qmake positives in both graph orientations. The unchanged +production artifact's broad 1081-case result remains its own earlier selection. +Exact assignments, commands and skip reasons belong to +[validation](VALIDATION.md#inc-qml-14-membership-projection) and +[traceability](../../tests/TRACEABILITY.md#project-membership-projection). + +The reviewed production tree is `a8656510281354913dcc92a3f733b70183874fbd`; the +version-0.9.74 wheel SHA256 is +`221c7efa8db69e6cad048aea67d88fe3049cf98bae933816017aba833525dc6c`. +All 155 Python payloads are byte-equal across reviewed source, wheel and isolated +installation. The installed public CMake/qmake/qrc fixture publishes 30 nodes, +31 edges and six resolved memberships (five source and one resource), including +an uninstantiated QML component. Real query/explain/affected/HTML commands exit 0. +Malformed qrc with force/partial options exits 1 and retains four durable products; +repair and repeat exit 0 with unchanged graph bytes on repeat. This is actual +installed static-analysis evidence, with no Qt application/build execution. + +No new browser, hosted, other-platform or executable Qt proof is claimed. Earlier +INC-QML-12/13 completion and policy-5 artifact evidence remain revision-specific; +At that membership checkpoint, INC-QML-08 adoption was partial and INC-QML-11/15 remained planned. Plan review +identified no additional required increment for the completed membership profile. + +## Final adoption delivery + +This section preserves the local adoption checkpoint before integration with +newer upstream. Its schema/policy, suite totals and pending outcomes belong to +that revision. Later integration evidence is linked above; it does not rewrite +these earlier results or prove a later contribution revision. + +INC-QML-11 resolves bounded multi-level inherited signals with declaration-owned +endpoints and conservative shadow/conflict/cycle rejection. INC-QML-15 preserves +distinct constructor signatures, original locations and declaration/definition +ownership through reload and updates. Explicit emit/Q_EMIT observations survive +unavailable declarations without inventing a signal endpoint or delivery call. + +INC-QML-08a admits bounded current-file qmake PWD paths and source-visible C++ +header markers, retaining C/Objective-C and ambiguous-header behavior. INC-QML-08b +uses canonical declared pointer/lvalue-reference APIs for factory/member providers +and child-service properties, calls and QML subscriptions. Lexical shadows, +conditional/ambiguous types, unsupported declarators and borrowed/corrupt source +proof remain unavailable. Ordinary static analysis executes no Qt application, +factory, build hook or plugin. + +INC-QML-29–38 preserve reference callables, logical dependency direction, exact +source-file role and occurrence identity across assembly, JSON, reload, query and +affected consumers. Integrity preflight runs before comparison shortcuts or +publication. Removal uses accepted prior ownership proof; partial refresh cannot +authorize orphan deletion. First minted external nodes receive semantic origin; +an unchanged second update cannot rewrite valid bytes solely for origin backfill. +Per-occurrence event transport remains bounded without recursive literal encoding. +The adoption checkpoint uses AST cache schema 12 and Qt policy 18. + +INC-QML-08c is locally complete for Windows x64/Python 3.12.14 with pinned parser +0.25.2/language-pack 0.11.0. The reviewed installed wheel passes 3,024 tests with +36 existing skips, and all 179 Python payloads match the reviewed source exactly. +Actual installed CLI CMake/qmake whole-root/safe-subroot initial/repeat profiles +pass, with hand-checked facts and equal graph bytes. Individual acceptance, +failure/repair and mutation proof is in [traceability](../../tests/TRACEABILITY.md#final-adoption-delivery); +commands and digest are in [validation](VALIDATION.md#final-adoption-delivery). + +At this adoption checkpoint, the final full source suite has 8,630 passes, +59 pre-existing failures and 178 skips; no new failure identities appear. Full +typing retains baseline errors with no added diagnostics. The hosted OS/Python +lanes, live service proof and native browser/device interaction remain unverified +at this checkpoint. Static declared API compatibility +does not establish Qt build success, plugin availability, runtime conversion, +overload dispatch, allocation, ordering, lifetime or thread safety. diff --git a/docs/qt-qml/PARSER_DECISION.md b/docs/qt-qml/PARSER_DECISION.md new file mode 100644 index 0000000000..d0916bf020 --- /dev/null +++ b/docs/qt-qml/PARSER_DECISION.md @@ -0,0 +1,84 @@ + + +# INC-QML-00 parser evidence + +Decision D2 is accepted for an **optional** `qml` extra using +`tree-sitter-language-pack==0.11.0`, with Tree-sitter `>=0.25.2,<0.26`. +This is a parser decision, not completed production QML support. + +The inspected upstream baseline is `0b60d47e6cd9338c51143f39f35b6c45c8453385`. +Proposal [#1748](https://github.com/Graphify-Labs/graphify/pull/1748) remains open +at `7b38d4c2e2226b1db826a26774c7a5f299b8d62f` as inspected on 3 October 2026. +Its flat names, import-context loss, partial-parser handling and C++ integration +require replacement or supplementation. No files or fixtures from that proposal +are copied by this increment. The probe corpus is newly written synthetic input. + +## Installed dependency and grammar + +The installed wheel declares `MIT OR Apache-2.0` and includes its LICENSE with +the package authors' notices. Grammar attribution remains in the dependency; +Graphify's existing LICENSE, LICENSE-MIT and NOTICE remain intact. The +[versioned grammar manifest](https://github.com/xberg-io/tree-sitter-language-pack/blob/v0.11.0/sources/language_definitions.json) +pins qmljs revision `0bec4359a7eb2f6c9220cd57372d87d236f66d59`. +Installed Windows files total 165,756,708 bytes, a reason to keep this optional. +The standalone 0.3.1 source-only binding is rejected for this slice because it +uses an older Tree-sitter interface and would require platform build maintenance. + +`get_parser`, `get_binding`, `Language` and `Parser` work with the locked +Tree-sitter 0.25.2. Both installed QML grammars report ABI 14; the runtime supports +13 through 15. No Qt SDK is needed. A fresh isolated interpreter blocks Python +socket, process and shell operations before importing the parser and successfully +parses the installed grammar. This proves the tested Python-level offline path; +it is not an operating-system sandbox or an offline dependency installation. + +## Executed evidence + +| Host | Python | Parser probes | Status | +| --- | --- | --- | --- | +| Windows x64 | 3.10.21 | 40 passed, zero skipped | Verified | +| Windows x64 | 3.12.14 | 40 passed, zero skipped | Verified | +| Windows x64 | 3.13.15 | 40 passed, zero skipped | Verified | +| Windows x64 | 3.14.7 | 40 passed, zero skipped | Verified | +| Linux / macOS | 3.10 / 3.12 / 3.13 / 3.14 | Not run locally | Unverified | + +The 12 source fixtures cover Qt 6.5/6.8 syntax profiles: imports, versions, +qualifiers, property modifiers, aliases, typed functions/signals, inline +components, enums, pragmas, bindings, handlers, JavaScript, grouped properties, +Unicode, CRLF, incomplete/malformed/empty input, qmldir and qmltypes syntax. +Source byte/point ranges are checked against literal input. This matrix is +syntax coverage; no Qt runtime was executed. Targeted Ruff and Pyright report +zero errors. Existing language/registry/identity/validation tests report +569 passed and 55 optional-dependency skips. + +Baseline findings remain separate: two Windows deleted-current-directory +fixtures fail before reaching production assertions; whole-repository Pyright +reports 634 existing errors and four warnings. They are not parser successes. + +## Contracts accepted for production increments + +Grouped properties parse as object definitions and require semantic separation. +An empty QML file has a grammar error: production must explicitly describe its +empty-editor-file policy. Malformed source must not become an authoritative +partial graph or successful cache entry. The qmldir grammar accepts invalid +versions and missing module URIs, so INC-QML-02 requires a dedicated semantic parser. + +Use explicit scan roots, case-sensitive lookup keys and normalized IDs containing +an exact-identity digest. Scope keys must be portable. Persist independent import, +export and use-site nodes; avoid lists silently truncated by metadata sanitation. +Resolver indexes are per run and borrowed context is immutable. Multiple event +sites need distinct IDs because the existing graph is a DiGraph. + +INC-QML-01 must reject failed QML extraction before publishing a graph or manifest, +including forced updates and equal-node-count losses. INC-QML-02/INC-QML-03 must widen a Qt +change to the whole accepted corpus or reject it safely until INC-QML-06 adds precise +invalidation. QML C++ bridges retain declaration identity and source-owned event +sites; runtime C++ implementation remains assigned to INC-QML-04. + +## Increment review + +INC-QML-00 is complete for optional parser selection, with Linux/macOS evidence +explicitly unverified. Add INC-QML-01a (write/cache safety) and INC-QML-01b (declarations, +admission and installation), following the existing letter-suffix convention. +Both are required before INC-QML-01 completion. INC-QML-02 must test semantic qmldir +validation, immutable indexes, metadata transport and changes to unchanged users. +No production requirement acceptance criterion is closed by this probe alone. diff --git a/docs/qt-qml/PLAN.md b/docs/qt-qml/PLAN.md new file mode 100644 index 0000000000..701b95ecc2 --- /dev/null +++ b/docs/qt-qml/PLAN.md @@ -0,0 +1,2909 @@ +# Qt and QML feature increment plan + +Current delivery scope is the [upstream contribution](UPSTREAM_CONTRIBUTION.md) +under INC-QML-53. The recorded full-fork checkpoints below retain their original +scope and source-specific evidence. Independent policy, native Codex hook and +general HTML viewer features are deferred from this contribution; its retained +Qt/QML and shared integrity contracts need fresh applicable proof. + +Use `INC-QML-` for delivery increments and `REQ-QML-` for requirements/criteria. +The [identifier catalog](IDENTIFIERS.md) retains every prior ID as a permanent alias. + +Shared installer, shell-fixture, filesystem-profile and Terraform verification +continues separately under [INC-CORE-01–03](../COMPATIBILITY.md). These contribution +gate corrections do not expand the Qt/QML language profile or advance its counter. + +Status: **INC-QML-11, INC-QML-15 and INC-QML-08a/b/c are implemented and locally +complete for the bounded Windows x64/Python 3.12 profile**. INC-QML-28–38 close +additional reproduced defects discovered during that development. The final +reviewed installed wheel passes 3,024 selected tests with 36 existing skips; +actual CMake/qmake whole-root and safe-subroot CLI profiles pass initial and repeat +updates. All seven REQ-QML-018 criteria have individual local acceptance evidence. +See [final validation](VALIDATION.md#final-adoption-delivery) and +[traceability](../../tests/TRACEABILITY.md#final-adoption-delivery). + +The earlier full source run has 8,630 passes, 59 failures and 178 skips. Its exact failure +identities equal the pre-work baseline; no new failures were added. Full typing +also retains baseline errors with no added diagnostic identities. These are +failed contribution gates, not passing release evidence. At that checkpoint, +changes had not been published or run through the twelve hosted OS/Python lanes. Local +completion does not fill those cells or establish native browser/device, live +database or Qt runtime proof. + +The later [Windows dependency follow-up](VALIDATION.md#windows-shell-and-optional-dependency-follow-up--2026-10-04) +installs portable Bash and locked optional packages without product changes. +Its selective rerun clears 40 of the original 59 failures, retains 19, and exposes +two previously skipped hook path assertions. No full-suite gate is inferred. + +The current [native compatibility evidence](../../tests/TRACEABILITY.md#current-native-source-and-artifact-evidence--2026-10-05) +at `463ca322` has 8,818 passes and no pytest failures, with separate setup replays. +[Draft PR 6](https://github.com/SlinkyRamey/graphify/pull/6) publishes the adoption +branch against the reviewed INC-QML-07 base. Its first hosted revision passes +four Ubuntu wheel lanes but rejects CI configuration before job admission and +fails eight Windows/macOS installed-smoke lanes. INC-CORE-06 corrects workflow +admission; INC-QML-39 corrects the reproduced canonical input-alias mismatch. +These failures remain unmet current hosted gates until corrected evidence exists. + +At hosted head `f95366d`, all twelve isolated QML/core wheel smoke profiles pass, +and the four macOS QML source profiles pass. Ubuntu source jobs expose two +discovered-symlink provenance failures; Windows QML source jobs expose seven +nearby-alias fixture setup errors, and the native compatibility job rejects its +duplicate Node application selection before pytest. INC-CORE-07 and INC-QML-42/43 +have passing local source/installed checkpoint evidence for those corrections. +Plan exit review reproduces shared-content watch invalidation and actual Windows +short-leaf identity gaps, assigned to INC-QML-44/45. The next normal PR revision +owns corrected hosted proof; no current full hosted pass is claimed. + +INC-QML-00–07 hosted results remain evidence for their recorded original profile. +INC-QML-09/13/16 implement community presentation and middle mouse navigation; +emitted-script/installed contracts pass, with native interaction still unverified. +INC-QML-10/12/14 and INC-QML-17–27 correction records retain their original +revision boundaries. The [API mechanism matrix](QT_API_COVERAGE.md) identifies +supported forms, conservative exclusions and generic-only extraction. It does +not promise individual whole-SDK semantics. + +Sections below preserve planning contracts and chronological checkpoint reviews. +Earlier pending statements and epochs describe those checkpoints; the current +delivery state is this summary and the final exit review. Commands marked +proposed are suggestions, not executed evidence. Increment identities remain +distinct from requirements and their acceptance criteria. + +This plan extends Graphify's existing Python pipeline and contribution workflow. +It does not propose a Qt application rewrite. Read [REQUIREMENTS.md](../REQUIREMENTS.md), +[ARCHITECTURE.md](ARCHITECTURE.md), [DESIGN.md](DESIGN.md), [AUDIT.md](AUDIT.md), +and the upstream [contribution guide](../../CONTRIBUTING.md) before +implementation. The approved baseline is the imported upstream `v8` state recorded +by the foundation audit; recheck upstream changes before starting each PR. + +Upstream already tracks this feature in [issue #1716](https://github.com/Graphify-Labs/graphify/issues/1716) +and [PR #1748](https://github.com/Graphify-Labs/graphify/pull/1748). The PR was open +when inspected for this foundation and includes QML extraction and a C++ bridge. +Its page contains older dependency notes superseded by later commits. INC-QML-00 +must review its current head, tests, dependencies, maintainer feedback and overlap +before deciding what to reuse, supplement or replace. Do not create a duplicate +issue or assume unmerged code is present in the imported baseline. + +## Sequence and working contract + +| Increment | Outcome | Prerequisites | Requirement references | +| --- | --- | --- | --- | +| INC-QML-00 | Existing-PR review, reproducible baseline and accepted parser decision | Foundation review | REQ-QML-001, REQ-QML-010, REQ-QML-012, REQ-QML-014, REQ-QML-015 | +| INC-QML-01 | Discover `.qml` and extract minimal source-backed declarations | INC-QML-00 | REQ-QML-001, REQ-QML-002, REQ-QML-003, REQ-QML-010, REQ-QML-011, REQ-QML-012, REQ-QML-013, REQ-QML-014, REQ-QML-015 | +| INC-QML-02 | Resolve explicit QML modules, local components and scoped names | INC-QML-01 | REQ-QML-002, REQ-QML-004, REQ-QML-005, REQ-QML-010, REQ-QML-012, REQ-QML-014, REQ-QML-015 | +| INC-QML-03 | Model bindings, aliases, JavaScript and signal relationships | INC-QML-02 | REQ-QML-006, REQ-QML-007, REQ-QML-010, REQ-QML-012, REQ-QML-014, REQ-QML-015 | +| INC-QML-04 | C++ exposure into QML, Qt signal connections and C++ access to QML APIs | INC-QML-04b after INC-QML-00; resolved INC-QML-04a joins after INC-QML-02; INC-QML-04c after INC-QML-03, INC-QML-04a and INC-QML-04b; metadata joins finish with INC-QML-05 | REQ-QML-008, REQ-QML-016, REQ-QML-017, REQ-QML-010, REQ-QML-012, REQ-QML-014, REQ-QML-015 | +| INC-QML-05 | Enrich modules and resources from static Qt project metadata | INC-QML-02; enrich INC-QML-04 when available | REQ-QML-002, REQ-QML-004, REQ-QML-008, REQ-QML-009, REQ-QML-017, REQ-QML-010, REQ-QML-012, REQ-QML-014, REQ-QML-015 | +| INC-QML-06 | Guarantee dependency-aware update/watch/cache parity | INC-QML-03, INC-QML-04, INC-QML-05 | REQ-QML-002, REQ-QML-011, REQ-QML-016, REQ-QML-017, REQ-QML-010, REQ-QML-012, REQ-QML-014, REQ-QML-015 | +| INC-QML-07 | Verify consumers, publish support matrix and prepare upstream release | INC-QML-00 through INC-QML-06 | REQ-QML-013, REQ-QML-014, REQ-QML-015; regression of initial REQ-QML-001–REQ-QML-017 | +| INC-QML-08 | Harden installed-project adoption with evidenced source forms and typed context chains | INC-QML-07; public reproductions and bounded contract review | REQ-QML-018; reverify affected REQ-QML-004/REQ-QML-007/REQ-QML-008/REQ-QML-009/REQ-QML-011/REQ-QML-012/REQ-QML-013/REQ-QML-014/REQ-QML-015/REQ-QML-017 | + +Keep the existing increment numbers as stable scope identifiers; they do not +require independent work to wait for every lower number. The QML lane follows +INC-QML-00, INC-QML-01, INC-QML-02 and INC-QML-03. Native Qt C++ events in INC-QML-04b can proceed +after INC-QML-00 establishes C++ identity and event-site projection contracts, without +waiting for QML parsing or JavaScript integration. Raw C++ exposure/access facts +can also be developed early against those agreed interfaces; resolved joins have +the prerequisites in the table. + +After INC-QML-02 establishes the module-index contract, INC-QML-05's independent metadata +readers may proceed alongside INC-QML-03 and the C++ work. Their bridge integration +waits for INC-QML-04a/INC-QML-04c facts. Minimal `qmldir` discovery belongs to INC-QML-02. Simple +literal-file access in INC-QML-04c can precede INC-QML-05; module-loaded components and qrc +aliases finish with INC-QML-05's index. This separates reader readiness from completed +bridge integration and avoids a circular prerequisite. + +```mermaid +flowchart LR + baseline["INC-QML-00: baseline and contracts"] --> declarations["INC-QML-01: declarations"] + declarations --> modules["INC-QML-02: modules and scope"] + modules --> interactions["INC-QML-03: bindings, JS and handlers"] + baseline --> events["INC-QML-04b: native Qt events"] + modules --> exposure["INC-QML-04a: resolved C++ exposure"] + modules --> readers["INC-QML-05: metadata readers"] + interactions --> access["INC-QML-04c: QML object access"] + exposure --> access + events --> access + readers --> joined["INC-QML-05: module/resource bridge joins"] + access --> joined + exposure --> joined + joined --> updates["INC-QML-06: dependency-aware updates"] + interactions --> updates + events --> updates + updates --> release["INC-QML-07: consumers and release evidence"] + release --> adoption["INC-QML-08: bounded installed-project adoption (locally complete)"] +``` + +## Reviewable work packages + +These are slices within existing increments, not new requirement or increment IDs. +Each slice has one owner and a focused diff. Internal adapters/facts may land before +admission, but public dispatch is enabled only with its complete extraction, +packaging, diagnostics, persistence and safe-update gate. Combine slices when a +separate PR would expose a broken path or have no independently useful outcome. + +| Increment | Work packages | Demonstrable result | +| --- | --- | --- | +| INC-QML-00 | Baseline/upstream overlap review; parser and syntax probe; fact/graph/update contracts | Reproducible parser choice and hand-checked corpus; no production admission | +| INC-QML-01 | Parser adapter and declarations; discovery/dispatch/packaging integration | A small QML file yields exact scoped declarations and spans, with safe failures and updates | +| INC-QML-02 | `qmldir` facts; URI/version/alias lookup; component/member scope | Equal names in different modules/components resolve correctly or remain ambiguous | +| INC-QML-03 | Bindings/aliases; embedded/imported JS; handlers and `Connections` | Property, call and signal dependencies preserve scope, source evidence and mechanism | +| INC-QML-04a | Meta-object/member overlay; literal procedural registration; declarative exports | QML references reach evidenced C++ APIs; build-derived module joins finish in INC-QML-05 | +| INC-QML-04b | Declarations/emissions; typed/overloaded/lambda connections; legacy signatures/private slots/disconnect | Native C++ events remain distinct from direct calls, including ordinary compatible receivers | +| INC-QML-04c | Loader/root provenance; objectName/member access; context/initial providers and signal joins | C++ accesses the evidenced QML object/member; unknown or duplicate targets remain unresolved | +| INC-QML-05 | CMake/qmake module readers; `.qmltypes`; `.qrc`; bridge integration | Equivalent Qt 6 project forms resolve modules/resources and both bridge directions without execution | +| INC-QML-06 | Invalidation/reconstruction; watch integration; mutation/parity corpus | Updates equal clean rebuilds after QML, C++, resource and metadata changes | +| INC-QML-07 | Query/affected; export/MCP/callflow; install/release evidence and generated guidance | Users can inspect the verified dependency graph through advertised consumers | +| INC-QML-08 | Public adoption reproductions; project/native source compatibility; typed context chains; installed/update safety | Newly accepted ordinary project forms work through the installed CLI with accurate uncertainty and preserved prior data | + +One integration owner controls shared discovery, dispatch, registry, graph, +cache/watch and resolver contracts. Parser, metadata, event and access contributors +own disjoint modules and fixture/test files against agreed interfaces. Give the +C++ overlay one owner; event/access slices supply focused modules rather than +concurrently editing its central integration file. Record ownership before work, +review commits at handoff, and coordinate every Git mutation. + +The first useful QML slice is INC-QML-01. Native Qt event analysis is a separate useful +slice in INC-QML-04b. Project-level mixed-language analysis needs INC-QML-02/INC-QML-03/INC-QML-04/INC-QML-05; +the release candidate also needs INC-QML-06/INC-QML-07. Estimate elapsed effort after INC-QML-00 +resolves parser packaging and reuse uncertainty, using those measured findings. + +## Increment readiness and completion + +An increment is ready when its reviewed source base, prerequisites, owner, affected +acceptance IDs, proposed fact/interface changes, hand-checked fixture expectations, +failure cases and verification commands are recorded. Inspect overlapping upstream +work before committing to a production approach. Tests/fixtures named below are +proposed and must exist before running their acceptance command. + +An increment is complete only when its implemented subset passes the common gate, +each due criterion has exact evidence, new failures have faithful regressions, +safe update/persistence behavior is exercised, and requirements/design/diagnostics/ +comments/traceability match the delivered result. Record modularity ceilings or +justified exceptions for touched source files. Partial implementation is a valid +checkpoint, not a completed criterion. Baseline failures, skips and missing lanes +remain explicit limitations rather than passing evidence. + +At each handoff, the integration owner reviews a versioned record of exact base, +head and tested SHAs, affected acceptance IDs with partial/passed/gap evidence, +interface/schema decisions, commands/results/platforms, file ownership, outstanding +risks, and the next owner. A future work package must not infer completion from a +previous contributor's summary alone. + +When publication is authorized, use one focused branch and one PR validation run +for the reviewed revision; preserve separate required post-merge checks. Record +source, tested integration and resulting SHAs with workflow results. Follow +[AGENTS.md](../../AGENTS.md) for guarded merging, retries and any future protected +proof. Upstream acceptance depends on maintainer review; it is not an automatic +result of finishing local implementation. + +## Acceptance completion ownership + +Every acceptance criterion has one planned completion increment in +[tests/TRACEABILITY.md](../../tests/TRACEABILITY.md). Earlier increments provide +partial evidence and later ones reverify affected contracts. This is a delivery +assignment, not a change to the criterion or a claim that it passed. Cross-cutting +criteria remain obligations in every affected PR, even when their complete +mixed-language evidence is assigned to INC-QML-06 or INC-QML-07. + +| Requirement | Planned criterion completion | +| --- | --- | +| REQ-QML-001 | AC01/AC03/AC04: INC-QML-01; AC02 complete supported-source corpus: INC-QML-03 | +| REQ-QML-002 | AC02/AC03 metadata/corpus boundaries: INC-QML-05; AC01/AC04 entry-point parity: INC-QML-06 | +| REQ-QML-003 | AC01–AC04: INC-QML-01 | +| REQ-QML-004 | AC01–AC04: INC-QML-02 | +| REQ-QML-005 | AC01–AC04: INC-QML-02; reverify added metadata visibility in INC-QML-05 | +| REQ-QML-006 | AC01–AC04: INC-QML-03 | +| REQ-QML-007 | AC01–AC04: INC-QML-03; reverify C++ signal joins in INC-QML-04/INC-QML-05 | +| REQ-QML-008 | AC01–AC04: INC-QML-05, following INC-QML-04a source exposure | +| REQ-QML-009 | AC01–AC04: INC-QML-05 | +| REQ-QML-010 | AC01–AC04: INC-QML-07; graph correctness gates apply when each fact lands | +| REQ-QML-011 | AC01–AC04: INC-QML-06 | +| REQ-QML-012 | AC01–AC04: INC-QML-06; safe failure/persistence gates apply from INC-QML-01 | +| REQ-QML-013 | AC01–AC04: INC-QML-07 | +| REQ-QML-014 | AC01–AC04: INC-QML-07; install/platform evidence starts in INC-QML-00/INC-QML-01 | +| REQ-QML-015 | AC01–AC04: INC-QML-07; documentation/evidence gates apply in every PR | +| REQ-QML-016 | AC01–AC03: INC-QML-04b; AC04 full parity/consumer evidence: INC-QML-07 | +| REQ-QML-017 | AC01–AC03: INC-QML-05 after INC-QML-04c; AC04 full parity/consumer evidence: INC-QML-07 | +| REQ-QML-018 | AC01/AC02/AC07: INC-QML-08a; AC03/AC04: INC-QML-08b; AC05/AC06: INC-QML-08c; safety gates apply to every slice | + +The parser spike informs REQ-QML-001; production install/failure acceptance closes in +INC-QML-01 and the complete supported syntax/declaration corpus in INC-QML-03. Parsing +success alone cannot replace those production-boundary assertions. +All named metadata and their corpus boundaries close in INC-QML-05. Exposure and +resource/module-backed access close in INC-QML-05 after the INC-QML-04 source slices. +Native event syntax closes in INC-QML-04b, while REQ-QML-016-AC04 and REQ-QML-017-AC04 need +incremental and consumer evidence before closing in INC-QML-07. The final release gate +rechecks all sixty-eight initial-profile criteria for the advertised matrix. +INC-QML-08 now has seven separately assigned criteria and reverifies affected original +contracts; existing passes do not establish the expanded adoption profile. + +The primary acceptance profiles are ordinary Qt 6.5 and Qt 6.8 source/metadata, +as specified in ARCHITECTURE. Both CMake and qmake project forms are required for +the first Qt 6 target. Literal procedural registration is also part of Qt 6 bridge +support; neither qmake nor procedural registration is restricted to legacy Qt. +Qt 5.15 is a separate legacy compatibility profile with separately reviewed PRs +within INC-QML-02, INC-QML-04 and INC-QML-05. Parsing common versioned-import syntax earlier +does not establish that profile's semantic support. Record module import versions +separately from the Qt release used to define a fixture. + +Each increment is a cohesive feature branch and one or more small PRs. A PR may +merge independently only when it delivers a truthful subset, preserves existing +behavior, and satisfies its exit gate. Broad schema migrations, graph-class +changes, unrelated refactoring, and new runtime integrations require separate +review. An upstream-ready PR is not an upstream-accepted PR; approval and merge +remain separate recorded events. + +Use existing `file_type="code"`, required source provenance, confidence values, +canonical ID helpers, ignore rules, and graph consumers. New QML detail belongs in +versioned metadata and focused resolver facts, with meanings defined in DESIGN. +Do not globally group QML/JavaScript with C++ to defeat cross-language call guards. +Do not activate a MultiDiGraph migration as an incidental language-support change. +Represent distinct bindings or handlers with their own source-backed nodes where +needed so the current simple graph does not silently overwrite relationships. + +## Common verification gate + +All paths below are relative to the repository root. Proposed new test files and +fixture directories are deliberately named so work can start without inventing +the test layout. Adapt names to an upstream-reviewed convention while retaining +the acceptance-criterion mapping. Every requirement has individually assigned +criterion IDs in REQUIREMENTS, such as `REQ-QML-003-AC01` through `REQ-QML-003-AC04`. +Before starting an increment, list all affected criterion IDs and map each one to +a concrete fixture/action/observable result and an exact automated test, or to an +explicit manual/system verification gap. Dedicated tests should use criterion +metadata or names such as `test_QML_003_AC01`; existing tests may map through +`tests/TRACEABILITY.md` without renaming unrelated tests. + +Every increment's exit gate requires criterion-level evidence in traceability: +test identity, command, result, supported profile/platform, and remaining gap. +Record applicability explicitly. All applicable assigned criteria must pass +individually before a requirement becomes `Verified`. A skipped test, unexecuted +manual check, aggregate test count, or one passing happy-path test does not verify +the other criteria. Retain `Planned`, `Implemented` or partial/unverified status +until the criterion-level evidence supports promotion; document any criterion +scope change in the requirement rather than silently treating it as a pass. + +Establish a pinned development environment with `uv sync --frozen`. If a PR changes +dependencies, deliberately regenerate and review `uv.lock` first; a frozen command +must not be used to pretend that an old lock covers a new dependency. Use the +selected QML extra after INC-QML-00 accepts its packaging contract. Do not substitute +live Qt execution or model calls for deterministic offline parser tests. + +Before handoff, execute the increment's focused tests and the following applicable +upstream checks. Capture failures, skips and unavailable optional dependencies. +The proposed pytest commands use Python's module runner and UTF-8 mode because +this project is developed on Windows. Existing POSIX CI may retain its equivalent +pytest entrypoint. The foundation's two remaining Windows-invalid deleted-working- +directory fixture failures are documented in VALIDATION; reproduce and classify +baseline failures instead of treating them as new Qt/QML regressions or claiming +the complete baseline is green. + +```text +uv run --frozen python -X utf8 -m pytest tests/ -q --tb=short +uv run --frozen ruff check . +uv run --frozen pyright +uv run --frozen python -m tools.skillgen --check +``` + +When optional dependencies are involved, reproduce the upstream CI environment +with `uv sync --all-extras --frozen` in a suitable clean job, or use an explicitly +documented extra selection for the focused job. A platform-specific dependency +failure is a finding to resolve or report, not permission to claim full coverage. +When code changes, follow the repository's instruction to run `graphify update .` +and check its result. Generated local graph/build outputs remain uncommitted. + +For every increment, review the full diff, canonical documents, test quality, +dependency provenance, and safe diagnostics. Record the actual commands and +results in `tests/TRACEABILITY.md`. Demonstrate regression tests fail against the +old behavior where applicable. Full-suite or cross-platform failures must be +explained before completion; unchanged aggregate coverage does not settle them. + +Every new fact shape must pass extraction, graph-build and JSON reload assertions +when introduced. Event/access packages also demonstrate their minimal query and +affected behavior before claiming usability. INC-QML-07 expands that coverage to the +complete advertised consumer matrix; it does not defer basic graph correctness. + +Before enabling an update/watch path, implement and test its conservative full- +project resolution/rebuild fallback or reject the unsupported operation before +mutating graph/cache state. Test deletion, failed extraction and stale-edge removal +at each newly enabled boundary. A warning or support note cannot justify leaving +an enabled path known to produce stale results. INC-QML-06 improves invalidation and +establishes full parity after this safety baseline. + + + +## INC-QML-00 — Baseline and parser decision + +**Status: complete for optional parser selection (3 October 2026).** See the +[parser decision and executed evidence](PARSER_DECISION.md). Linux/macOS lanes +remain unverified; this does not authorize default-installed parser promotion. +INC-QML-01 includes **INC-QML-01a** (write/cache safety) and **INC-QML-01b** (declarations, +admission and installation) as required INC-QML-01 work packages. A dedicated semantic +qmldir parser and nonlossy, immutable fact transport are mandatory in INC-QML-02. +Production acceptance criteria remain open until exercised through production. + +**Scope and code paths.** First inspect the current exact head of PR #1748 without +changing this workspace to its branch. Review its extractor, C++ modifications, +dependency metadata, tests, graph-family policy and mergeability against the +current upstream base. Map its existing coverage to the requirements in this plan. +Record a reuse/supplement decision and preserve attribution and license notices +for any later adopted code. Read-only inspection does not authorize posting review +comments or messages to other contributors. + +The source audit identifies review cases around flat scope/name tables, discarded +import alias/version context, partial-parse reporting, C++-only resolver activation, +and compatibility of C++ changes with current upstream normalization. Reproduce +these against the recorded candidate head rather than assuming its tests exclude +them. Include the valid identifier form `QML_NAMED_ELEMENT(Backend)`; +[Qt 6.8's macro documentation](https://doc.qt.io/qt-6.8/qqmlintegration-h.html#QML_NAMED_ELEMENT) +shows an identifier argument. A test built only around quoted macro syntax does +not validate the real contract. Also test macro-like text in comments/literals. + +Create public, synthetic fixtures under +`tests/fixtures/qml/parser_probe/` and a focused `tests/test_qml_parser_probe.py` +or equivalent nonshipping probe. Record the baseline commit, environment, current +language behavior, and parser ADR in the Qt/QML architecture/design documents. +Inspect `pyproject.toml`, `uv.lock`, `.github/workflows/ci.yml`, +`graphify/extractors/models.py`, `graphify/extractors/engine.py`, `graphify/ids.py`, +and `graphify/validate.py`. Keep experimental adapters out of production dispatch. + +Evaluate the direct QML grammar binding and the pinned language-pack option +identified by the architecture audit. Do not choose a package from its name or +advertised grammar list alone. Verify its license, grammar revision, Tree-sitter +API/ABI compatibility, actual installed parser availability, source ranges, +error recovery, distribution size, and offline behavior. A required dynamic +grammar download fails the offline installation/extraction gate. + +Start the comparison with the audited `tree-sitter-language-pack==0.11.0` candidate, +already optional upstream, and verify its actual installed `qmljs`/`qmldir` +entry points against the locked runtime. Treat the standalone +`tree-sitter-qmljs==0.3.1` as an alternative with explicit source-build and older- +binding risks. Record selection or rejection in existing decision D2. Prefer an +optional `qml` extra for initial adoption unless evidence supports a different +reviewed packaging decision; a package name/range intersection is not API proof. + +**Acceptance cases.** Parse imports with and without versions and aliases, objects, +nested objects, properties and modifiers, aliases, signals, methods, inline +components, JavaScript expressions, handlers, and deliberately incomplete files. +Probe `qmldir` independently; a small dedicated metadata parser is an acceptable +decision if its syntax and failure behavior are explicit. Unicode identifiers, +CRLF, comments containing braces, strings containing punctuation, and malformed +input must preserve useful ranges without a crash. The probe must never execute +QML or JavaScript. Missing grammar must yield a safe explicit limitation. + +Verify installation and parser invocation on Python 3.10, 3.12, 3.13, and 3.14, +matching upstream's current Python CI matrix; include Windows x64, Linux, and +macOS compatibility evidence or record each unsupported/unverified combination. +Build a syntax matrix using version-tagged source fixtures rather than claiming +runtime support for all Qt releases. Pin the supported syntax subset before INC-QML-01. + +Use focused packaging/probe jobs for additional platforms; the audited workflow +currently covers Ubuntu and does not prove Windows/macOS support. Advertise only +tested lanes and record architecture where relevant. Keep source/grammar/license +fingerprints and an explicit reused-file/test ledger at the reviewed PR head. +Retain the known Windows deleted-cwd fixture failures and type-check baseline as +separate findings; any portability correction is a focused change, not a weakened +parser gate or an unrelated bulk rewrite. + +**Proposed commands.** The probe test is introduced by this increment; it must not +be entirely skipped in the job used to accept the parser. + +```text +uv run --frozen python -X utf8 -m pytest tests/test_qml_parser_probe.py -q --tb=short +uv run --frozen python -X utf8 -m pytest tests/test_languages.py tests/test_multilang.py tests/test_extractors_registry.py tests/test_id_normalization_contract.py tests/test_validate.py -q --tb=short +``` + +**Exit gate and PR boundary.** Accepted parser ADR, dependency/license decision, +install matrix, synthetic corpus, meaningful recovery tests, baseline evidence, +and the existing-PR reuse/overlap decision. Accept the C++ declaration identity, +source-fact ownership, nonlossy event-site projection and safe-update fallback +design contracts required by the first production slices. Runtime implementation +of that fallback belongs to the first enabled update path, not to the probe. +No release language-support claim. Keep the parser choice/fixture PR separate from +production extraction if that makes review clearer. A missing cross-platform +wheel or incompatible ABI blocks promotion to default-installed support; document +an optional extra or defer instead of silently adding a compiler requirement. + + + +## INC-QML-01 — Discovery and minimal declarations + +**Status: INC-QML-01a/INC-QML-01b complete for the declared Windows optional profile.** See +[implementation evidence](IMPLEMENTATION.md). INC-QML-02/INC-QML-03 require semantic +validation, immutable context and independent use sites. No new top-level +increment is needed; INC-QML-06 still owns cache optimization. + +**Scope and code paths.** Add `graphify/extractors/qml.py` with an isolated parser +adapter and QML facts as necessary. Integrate `graphify/detect.py`, the public +facade and `_DISPATCH` in `graphify/extract.py`, and +`graphify/extractors/__init__.py`; registry entry alone is insufficient. Add the +accepted dependency/extra to `pyproject.toml` and `uv.lock`. Use +`graphify/ids.py`, `graphify/cache.py`, and `graphify/diagnostics.py` through their +existing contracts. Add `tests/test_qml_extract.py` and +`tests/fixtures/qml/declarations/`; update upstream language and detection tests. + +Extract file/component declarations, nested object scopes, `id` declarations, +properties, signal declarations, and method declarations with source ranges. +Record imports as raw facts or import records without guessing a target. Method +bodies and bindings remain opaque source facts until INC-QML-03. A declared property +alias may be recognized here while its target relationship remains unsupported. +Keep QML semantic information in `metadata.qml` and the versioned fact contract +specified in DESIGN. Represent `id` as component-local identity, not an ordinary +globally exported property. Preserve the full filename and component/symbol scope +when deriving canonical IDs; prove exact-case/punctuation collision handling. + +**Acceptance cases.** Directory and single-file scans discover `.qml` and +`.ui.qml`, obey `.gitignore`/`.graphifyignore`, and leave existing classifications +unchanged. Valid declarations retain source-backed fields and correct ranges; +two files with the same stem, `.qml`/`.js` stem collisions, case-distinct names, +nested identical IDs, and relocation to a different absolute checkout do not merge +unrelated declarations. Malformed or unsupported input yields bounded diagnostics +and a partial/failed status that cannot replace valid persisted state. Missing +optional grammar does not make an unrelated Python/C++ scan fail. Repeated scans +preserve IDs, confidence and order after canonical comparison. + +**Proposed focused commands.** + +```text +uv run --frozen python -X utf8 -m pytest tests/test_qml_extract.py tests/test_detect.py tests/test_extractors_registry.py tests/test_languages.py tests/test_extract_cli.py tests/test_extract_code_only_cli.py tests/test_validate.py tests/test_node_id_canonical.py tests/test_partial_extraction_warning.py tests/test_zero_node_no_cache.py -q --tb=short +``` + +**Exit gate and PR boundary.** One PR delivers discovery plus a minimal extractor, +public fixtures, dependency packaging, extraction validation, a cold/warm smoke +test, and an honest declarations-only support note. Do not ship discovery alone +that routes QML into an unusable extractor. Existing zero-node/shrink/cache guards +must apply. Until INC-QML-06, resolver-changing input must use a tested safe full +project resolution/rebuild path or be rejected before writes. Include admission, +edit, deletion and forced parser/write failure evidence for the enabled update and +watch paths; a known stale-graph path must not remain enabled with only a warning. + +REQ-QML-001-AC01 also requires a clean built-wheel installation with the selected QML +extra and production parser invocation on every declared lane. Extend the existing +wheel-packaging coverage and use isolated install/probe jobs; source-tree imports +or INC-QML-00's experimental adapter are insufficient. Test a core-only installation +without that extra. REQ-QML-001-AC02 stays open until INC-QML-03's production adapter passes +the complete declared syntax/declaration/span corpus, including embedded JS forms. + + + +## INC-QML-02 — Modules and component scope + +**Status: complete for the documented static profile.** Work packages cover **INC-QML-02a** semantic +qmldir, **INC-QML-02b** immutable module/member indexes and **INC-QML-02c** transport, +producer provenance and provider-only refresh parity. These follow the existing +letter-suffix numbering; all must pass to close INC-QML-02. No new top-level increment. + +**Scope and code paths.** Add `graphify/qml_resolution.py` and focused +`graphify/extractors/qml_metadata.py`, with registry integration through +`graphify/resolver_registry.py` and `graphify/extract.py`. Introduce shared +named-file discovery for `qmldir` in the existing corpus-boundary path; update +`collect_files()` and watcher recognition without creating a separate ignore +policy. Add `tests/test_qml_resolution.py`, `tests/test_qml_metadata.py`, and +`tests/fixtures/qml/modules/`. Use a per-project module/scope index with explicit +inputs and lifetime; retain raw facts separately from resolved graph edges. + +Resolve directory imports, module URI imports, aliases, local QML components, +version declarations, `qmldir` type entries, inline-component scopes, and singleton +metadata according to the accepted static policy. Record unresolved or conflicting +imports rather than searching every basename in the repository. Preserve scope +boundaries for IDs, component members, and inline components; do not infer dynamic +delegate/context objects from lexical proximity alone. + +**Acceptance cases.** Two modules both export `Button`; an aliased import resolves +only the intended module, while an ambiguous unqualified use has no guessed +concrete target. Imported versions, versionless imports, relative directory imports, +missing components, missing/duplicate module metadata, singleton declarations and +inline components have documented positive and rejection cases. Identical IDs in +separate component scopes stay distinct. An ignored or out-of-corpus `qmldir` does +not grant access to its files. Metadata-only changes trigger the conservative +resolution path even when QML text is unchanged. Synthetic Qt built-in references +remain external/unresolved unless a source-backed type inventory is supplied. + +**Proposed focused commands.** + +```text +uv run --frozen python -X utf8 -m pytest tests/test_qml_resolution.py tests/test_qml_metadata.py tests/test_qml_extract.py tests/test_language_resolvers.py tests/test_case_sensitive_resolution.py tests/test_import_self_loops.py tests/test_phantom_cross_package_call.py -q --tb=short +``` + +**Exit gate and PR boundary.** Module lookup/scope rules and ambiguity evidence are +documented and exercised through full extraction. A small named-file discovery +PR may precede the resolver when independently useful and tested. Module resolution +is a separate PR from later binding/call semantics. Keep the module-index contract +small enough for C++ and project metadata enrichment without a generic resolver +rewrite. No global same-name fallback or implicit Qt SDK scan. + + + +## INC-QML-03 — Bindings, aliases, JavaScript and signals + +**Status: complete for the documented static profile.** REQ-QML-001-AC02 and all +REQ-QML-006/REQ-QML-007 criteria pass production fixtures. Actual tests are in +test_qml_syntax_profile.py, test_qml_expressions.py, test_qml_handlers.py, +test_qml_scripts.py, test_qml_adversarial.py and the graph/persistence suites. +See [implementation evidence](IMPLEMENTATION.md); proposed names below describe +the initial planning contract and are superseded by those actual owners. + +INC-QML-03 comprises **INC-QML-03a** held-object/array/template declarations, **INC-QML-03b** +lexical expressions/aliases/handlers and **INC-QML-03c** accepted-script overlays, +generic-JS isolation, typed cross-family proof and durable edge direction. All +three work packages are delivered. Regression discoveries include lexical-list +truncation, duplicate anonymous Connections, inherited signal parameters, +mixed Connections handler styles, alias cycles, module export roles and missing +script-file endpoint proof. Separate source sites retain repeated relationships. + +No new top-level increment is needed. Add **INC-QML-06a** source/metadata/script/C++ +mutation parity and **INC-QML-06b** bounded parser/config/import-root cache contracts; +add **INC-QML-07a** direction/evidence across every advertised consumer and **INC-QML-07b** +hosted installation/profile evidence and upstream review. These refine existing +increments, preserving INC-QML-00 through INC-QML-07 and INC-QML-04a/INC-QML-04b/INC-QML-04c numbering. +Runtime contexts, framework members, reexports and name-based template barriers +remain documented conservative limits. Native C++ work advances in INC-QML-04 next; +build/resource/type-description enrichment remains INC-QML-05. + +**Scope and code paths.** Extend focused QML extraction/resolution modules, reusing +existing JavaScript AST helpers only through a narrow adapter that preserves QML +lexical scope and original source offsets. Add +`tests/test_qml_bindings.py`, `tests/test_qml_javascript.py`, +`tests/test_qml_signals.py`, and `tests/fixtures/qml/interactions/`. Map relations +through existing `references`, `uses`, and validated `calls` semantics with QML +context metadata. Binding and handler nodes retain distinct source evidence and +avoid same-endpoint relation loss in the current simple graph. + +**Acceptance cases.** Property binding reads link to the correct local ID/member; +property aliases retain the target and alias distinction; scoped local variables +shadow component members; inline-component and imported-JavaScript scopes do not +leak. Test expressions, blocks, functions, closures, `.js` imports, `.pragma +library` where accepted by the syntax matrix, signal declarations/emissions, +`onSignal` handlers and `Connections`. A handler's reference to a signal is not +proof of a runtime call. Dynamic `Connections.target`, computed property names, +runtime object creation and unresolved context values stay visibly unresolved or +qualified as inference. Two relationships between the same logical objects must +retain distinct evidence after build/merge/JSON round trip. + +**Proposed focused commands.** + +```text +uv run --frozen python -X utf8 -m pytest tests/test_qml_bindings.py tests/test_qml_javascript.py tests/test_qml_signals.py tests/test_js_import_resolution.py tests/test_js_callback_calls.py tests/test_indirect_call_block_scoped_shadow.py tests/test_build_merge_hyperedges_and_prune.py tests/test_relation_collapse_precedence.py -q --tb=short +``` + +**Exit gate and PR boundary.** Split bindings/aliases and JS/signals into separately +reviewable PRs if necessary. Each PR must preserve source ranges, scope, uncertainty, +and relation meaning, with cold/warm output parity. Update diagnostics and the +support matrix for dynamic limitations. No execution of user expressions, no +wholesale reuse of JavaScript resolution without QML scope context, and no claim +of complete runtime signal tracing. + + + +## INC-QML-04 — Qt/C++ exposure, signals and access to QML + +**Scope and code paths.** Add a source-overlay module such as +`graphify/extractors/qt_cpp.py`, enriching existing C++ declarations rather than +replacing the C++ extractor. Extend QML project resolution with explicit type +registration and member evidence. Inspect `graphify/extractors/engine.py`, C++ +preprocessing, `graphify/build.py` cross-family call guards, and canonical IDs. +Add `tests/test_qt_cpp_bridge.py`, `tests/test_qt_signals_slots.py`, +`tests/test_qml_cpp_access.py`, and synthetic fixtures under +`tests/fixtures/qml/cpp_bridge/`, `qt_connections/`, and `cpp_access/`. + +Keep three focused PR boundaries without renumbering the surrounding increments: + +- **INC-QML-04a:** C++ types/members exposed into QML, advancing REQ-QML-008 with + registration/member evidence; complete module-derived acceptance in INC-QML-05. +- **INC-QML-04b:** Qt C++ signal declarations, emissions and explicit connections, + completing `REQ-QML-016-AC01` through `REQ-QML-016-AC03` and the source/graph part of + `REQ-QML-016-AC04`; its full incremental/consumer criterion closes in INC-QML-07. +- **INC-QML-04c:** C++ consumers of QML objects, functions, properties and signals, + advancing all REQ-QML-017 criteria; complete module/resource-backed AC01–AC03 in + INC-QML-05 and full incremental/consumer AC04 in INC-QML-07. + +Each sub-PR must map every affected criterion to its own observable assertions and +traceability evidence. Later INC-QML-06 verifies invalidation of these facts, and +INC-QML-07 verifies their query/export/MCP projection. Criterion-level status remains +unverified for those later obligations until their evidence exists. + +Begin with statically identifiable `Q_OBJECT`, `Q_PROPERTY`, signals/slots, +`Q_INVOKABLE`, QML registration macros, and literal `qmlRegisterType`/singleton +registration calls supported by the accepted design. Treat literal +`setContextProperty` names and known instance types as evidence with their engine +and scope limits; uncertain ownership must remain inferred/unresolved. Use +`uses`/`references` and `qml_cpp_member` context first. Concrete cross-language +`calls` require narrowly validated registration/member evidence and dedicated +negative tests before extending a guard. QML_ELEMENT module URI enrichment that +requires build metadata remains pending until INC-QML-05. + +**Acceptance cases.** A QML property or method reference links only to the registered +class/member in the correct URI/version/registration scope. Test declaration and +implementation separation, getters/setters/NOTIFY evidence, overloaded members, +namespace-qualified C++ types, header/source duplicates, nonliteral registration, +conflicting registrations and unknown context-property types. A same-named member +in an unrelated C++ module must not acquire an edge. Macros inside comments or +strings are not registrations. Valid identifier-based `QML_NAMED_ELEMENT(Backend)` +must work; malformed or quoted forms must not substitute for that positive case. +Missing generated metadata or plugin binaries does +not trigger execution, an SDK scan or a guessed linkage. Removing or renaming a +registration on a C++-only change must reach the safe rebuild path. + +**Qt signal/connect/slot acceptance cases (REQ-QML-016).** Retain signal and slot +declarations/signatures and distinguish an emission from an ordinary method call. +Cover `signals`, `Q_SIGNALS`, `Q_SIGNAL`, slot access sections, `Q_SLOTS`, `Q_SLOT`, +and both `emit` and `Q_EMIT` forms with original source spans. +Extract source-backed `QObject::connect` sites for member-pointer syntax, legacy +`SIGNAL`/`SLOT` signatures, signal-to-signal connections and lambda/functor receivers. +Typed connections may target a compatible ordinary member; do not require a slot +annotation when the typed connection form supplies sufficient member evidence. +Resolve explicitly selected +overloads, including supported `qOverload` or cast forms, only when the declaring +type and signature identify a unique member. Lambda connections retain the lambda +body's ordinary direct calls separately from the signal-to-lambda relationship. +Preserve sender, signal, receiver/context, slot/lambda, source span, conditional +registration evidence and a declared connection type/flags where present. +Record `Qt::AutoConnection`, direct, queued or +blocking declarations as configuration facts without predicting runtime scheduling, +delivery order, thread affinity or whether a connection is successfully established. +In particular, retaining `Qt::UniqueConnection` on a lambda/functor connection +does not establish effective uniqueness or deduplication; test that the graph +preserves the declared flag without asserting that runtime effect. + +Test header/implementation pairs, namespaced classes, inherited members, duplicate +method names in unrelated classes, overload disambiguation, unavailable receiver +types, malformed legacy signatures, macro-like text in comments/literals, dynamic +targets and anonymous-lambda scope. Include supported private-slot meta-object +connections without confusing ordinary C++ call visibility with the meta-object +connection contract. A typed callable remains distinct from a same-named global +function; validate the actual endpoint/signature rather than its label. +Unresolved endpoints yield bounded diagnostic +evidence instead of guessed links. Connection sites and emissions retain their own +identity/context through graph construction; they must not become an ordinary +`calls` edge implying execution of the connected slot. Preserve supported +`QObject::disconnect` source statements as separate facts; a disconnect declaration +does not prove execution or justify erasing a prior connection from the source +graph. Ordinary direct slot calls, emit sites, connect sites and disconnect sites +must remain distinguishable after build/export/query/affected and incremental +updates. + +INC-QML-04b must also pass on a Qt C++-only corpus with no `.qml` files and no optional +QML parser installed. Activation cannot depend on a QML suffix or parser presence. +Unrelated APIs named `connect` or `emit` must not acquire Qt event relationships. +This independent acceptance case enables useful native Qt support in parallel +with the QML lane. + +**C++ access to QML acceptance cases (REQ-QML-017).** Collect literal `load` and +`loadFromModule` requests and `QQmlComponent` creation as access/creation facts with +the owning engine/component. Include the common Qt Quick `QQuickView::setSource` +and `rootObject` loader/access profile with its owning view. Track bounded +source-backed object flow from creation, `rootObjects()` or `rootObject()` to a +known QML root without assuming every engine/view uses the first same-named file. +Resolve literal `objectName`/`findChild` lookups only against the +identified object tree; a QML `id` alone does not establish the runtime lookup name. +Model `QMetaObject::invokeMethod` against declared QML function/signature evidence, +property reads/writes against the declared member, and C++ connections to declared +QML signals separately from ordinary direct calls. Also preserve exposed C++ signal +to QML-handler relationships in the appropriate receiver scope. Supported literal +`setContextProperty`, `setContextObject` and initial-property exposure carry their +provider, engine/component and provenance facts; reuse INC-QML-04a's exposure model +instead of creating competing definitions. Conditional or dynamic exposure remains +visibly uncertain. Annotate static access intent +without claiming object creation, lookup, mutation, invocation or signal delivery +occurred successfully at runtime. + +Test separate engines loading equal component names, multiple/unknown root objects, +module-loaded and URL-loaded components, separate QQuickView instances and their +setSource/rootObject provenance, objectName versus id, nested objects, +duplicate/dynamic object names, literal and computed method/property names, +overloads, read-only/unknown members, and QML signal-to-C++ slot connections. +Include C++ signal-to-QML handler cases and literal/dynamic context-property, +context-object and initial-property exposure variants. +Uncertain loader paths or receiver provenance remain unresolved. Resource aliases +and module URI/export mappings consume INC-QML-05's index instead of a global basename +search. Never instantiate an engine, create a component or load a plugin to resolve +these source relationships. +Qt Quick Widgets loaders remain an explicitly deferred, separately reviewed +extension; the QQuickView source profile does not imply widget integration support. + +Use the established `uses`/`references` projection plus namespaced connection, +emission, loading and member-access context defined in DESIGN. Dedicated site nodes +preserve distinct evidence when the simple graph would collapse endpoint pairs. +Use proposed contexts `qt_signal_emit`, `qt_connect_signal`, +`qt_connect_receiver`, `qt_disconnect`, `qt_cpp_qml_load`, +`qt_cpp_qml_find_child`, `qt_cpp_qml_property_read`, +`qt_cpp_qml_property_write`, `qt_cpp_qml_invoke`, `qt_context_exposure` and +`qt_initial_property`, with `metadata.qt.bridge_direction` and scoped +engine/component/root provenance. These are planned metadata contracts, not +already implemented graph fields. Reserve ordinary `calls` for evidenced direct +C++/JavaScript calls, including direct slot calls. A reflective QML invocation site +records access intent and its member evidence without implying an ordinary direct +call or runtime execution. Connecting a signal, emitting it and invoking a method +remain different source actions even when they mention the same member name. + +**Proposed focused commands.** + +```text +uv run --frozen python -X utf8 -m pytest tests/test_qt_cpp_bridge.py tests/test_qt_signals_slots.py tests/test_qml_cpp_access.py tests/test_qml_resolution.py tests/test_cpp_preprocess.py tests/test_cpp_method_declarations.py tests/test_cpp_objc_cross_file_calls.py tests/test_cross_language_call_resolution.py tests/test_cross_repo_external_call_guards.py -q --tb=short +``` + +**Exit gate and PR boundary.** Ship a narrow metadata overlay/registration PR before +broader bridge behavior if appropriate. Preserve existing C++ extraction and guards. +Every concrete exposure bridge edge has source-backed registration and member +evidence; connection/access edges have source-backed endpoint and object-flow +evidence. Confidence matches the actual claim. Review REQ-QML-016/REQ-QML-017 criteria +individually, including negative paths and distinct relation preservation. No +unrestricted name-based bridge or runtime event scheduling inference. Runtime +contexts, plugins and dynamic registrations remain documented gaps. Metadata-only +or C++-only changes use the conservative safe rebuild path until INC-QML-06 proves +targeted invalidation; metadata-backed access cases finish with INC-QML-05 integration. + + + +## INC-QML-05 — Build, module and resource metadata + +**Scope and code paths.** Extend `qml_metadata.py` or separate focused Qt project +readers for `CMakeLists.txt`/`.cmake`, `.pro`/`.pri`, `.qrc`, `qmldir` and +`.qmltypes`. Reuse discovery and ignore boundaries from INC-QML-02; inspect existing +`graphify/manifest.py`, `graphify/manifest_ingest.py`, `graphify/detect.py`, +`graphify/extract.py`, and watch recognition. Add +`tests/test_qt_project_metadata.py`, `tests/test_qt_resource_resolution.py`, and +`tests/fixtures/qml/project_metadata/`. Generated files remain distinguishable from +authoritative handwritten sources and do not silently override stronger evidence. + +Parse a declared static subset of `qt_add_qml_module`, module URI/version, +`QML_FILES`, related C++ source lists, supported qmake statements, and resource +prefix/alias/file mappings. Use `.qmltypes` only within its documented evidence +limits. Resolve resource URLs through `.qrc` mappings; preserve both logical URL +and canonical source path. Do not run CMake, qmake, Qt tooling or project scripts. +Unsupported expansion, conditionals, generators or binary plugin metadata must +remain explicit limitations rather than evaluated code. + +**Acceptance cases.** A literal resource alias maps to the intended in-corpus QML +file, including case-sensitive filenames and portable separators. Compare CMake +and qmake fixtures declaring the same simple module in both initial Qt 6 profiles; +both build forms are required for the first target. Test conflicting URIs, +overlapping qrc aliases, missing files, duplicate entries, relative paths, malformed +XML/metadata, conditional build statements, unknown variable expansion, and +resource paths escaping the approved corpus. XML readers must not resolve external +entities. A project with no Qt commands must not gain fabricated Qt modules. +Generated `.qmltypes` cannot overwrite real source provenance. `.qrc` or build +metadata-only changes trigger safe resolution before optimized invalidation exists. +Loader/access facts from INC-QML-04c must resolve the same intended component through +literal file/resource URLs and accepted `loadFromModule` URI/type mappings; missing +or conflicting metadata must not select an unrelated equal-name component. + +**Proposed focused commands.** + +```text +uv run --frozen python -X utf8 -m pytest tests/test_qt_project_metadata.py tests/test_qt_resource_resolution.py tests/test_qml_metadata.py tests/test_qt_cpp_bridge.py tests/test_qml_cpp_access.py tests/test_detect.py tests/test_manifest_ingest.py tests/test_non_regular_files.py tests/test_security.py -q --tb=short +``` + +**Exit gate and PR boundary.** Prefer one PR for static CMake/qmake module records +and one for qrc/resource resolution. Named-file discovery is shared and tested. +Every supported construct has an explicit static policy and unresolved fallback. +No implicit build execution, external traversal or unconditional generated-source +indexing. Metadata enriches QML/C++ indexes without redefining Graphify's build. + + + +## INC-QML-06 — Incremental updates, watch and caches + +**Scope and code paths.** Integrate accepted fact/index contracts with +`graphify/cache.py`, `graphify/manifest.py`, `graphify/watch.py`, the CLI's incremental +extraction path, `graphify/resolver_registry.py`, and the language resolver. Add +`tests/test_qml_incremental.py`, `tests/test_qt_watch_metadata.py`, and +`tests/fixtures/qml/incremental/`. Keep per-file syntax caching distinct from +project-dependent resolution; parser/fact-contract changes invalidate compatible +namespaces, while module/resource/registration changes invalidate their dependents. + +Record explicit dependencies from a file's raw imports/references to relevant +module, registration, resource and build metadata. Re-resolve the dependent closure +for changed, added, moved and deleted inputs. If the closure is uncertain, fall back +to a safe full project resolution. Avoid reparsing unchanged source solely because +resolved facts changed, while preserving correctness ahead of optimization. +Resolver activation cannot depend solely on changed `.qml` suffixes. Named metadata +files and C++-only changes must update unchanged QML consumers. +The reverse direction also applies: QML declaration/objectName/signal edits must +re-resolve unchanged C++ loader, lookup, property, invocation and connection sites. +Keep context/initial-property providers and connection/disconnect site ownership +in the dependency index instead of treating them as anonymous global name facts. + +**Acceptance cases.** Cold extraction, warm cached extraction, forced rebuild and +incremental update produce equivalent canonical nodes/edges/provenance for the +same final corpus. Test QML-only edits, imported-component deletion, qmldir-only +version/export changes, C++ registration-only edits, qrc alias renames, CMake/qmake +membership changes, same-mtime content changes, parser/fact schema upgrades, +checkout relocation and custom output directories. Watch batches recognize named +files and preserve existing debounce/locking/retry rules. Unsupported or partial +re-extraction cannot erase valid prior graph state or poison caches. Edge removal +and repointing are verified even when total node count remains unchanged. +For `REQ-QML-016-AC04` and `REQ-QML-017-AC04`, mutate a signal/slot signature, connection or +disconnect declaration, QML `objectName`/function/property, loader URL/module, +resource mapping and C++ context/initial-property provider. Verify both integration +directions equal a clean rebuild, while preserving distinct direct-call, emission, +connection and access facts and removing only source-backed stale results. + +**Proposed focused commands.** + +```text +uv run --frozen python -X utf8 -m pytest tests/test_qml_incremental.py tests/test_qt_watch_metadata.py tests/test_qt_signals_slots.py tests/test_qml_cpp_access.py tests/test_incremental.py tests/test_incremental_mtime_collision.py tests/test_watch.py tests/test_watch_manifest_location.py tests/test_cache.py tests/test_partial_cache.py tests/test_stale_prune.py tests/test_incomplete_build_guard.py tests/test_stat_index_portability.py -q --tb=short +``` + +**Exit gate and PR boundary.** A dependency-invalidation PR and a watcher integration +PR are acceptable when each has faithful parity tests and a safe fallback. Preserve +atomic writes, shrink guards, retained semantic layers and unresolved pending work. +Record invalidation cost and cache-hit/reparse evidence on the public fixture +corpus; do not invent a performance target before measuring the baseline. Remove +the earlier conservative full-rebuild limitation only after these cases pass. + + + +## INC-QML-07 — Consumers, support matrix and upstream delivery + +**Scope and code paths.** Audit and extend only the consumers that need explicit +Qt/QML behavior: `graphify/cli.py`, `graphify/affected.py`, `graphify/analyze.py`, +`graphify/export.py`, `graphify/exporters/`, `graphify/callflow_html.py`, +`graphify/serve.py`, and `graphify/report.py`. Use source fragments under +`tools/skillgen/fragments/` for generated assistant guidance; never hand-edit the +generated skill bodies. Delivered consumer tests are the focused +`tests/test_qt_*consumers.py`, `test_qt_graph_html_payload.py`, +`test_qt_export_matrix.py`, `test_qt_source_coverage_report.py` and +`test_qt_qml_search.py` suites, with actual HTTP/stdio MCP tests and real +incremental regression fixtures. Their exact assignments are in TRACEABILITY.md. + +**Acceptance cases.** A public mixed Qt/C++/QML/JavaScript fixture can be indexed, +queried, traversed by affected/path tools, exported and served via the offline MCP +test transport. Source locations, scope, ambiguity, relation direction and QML +context survive supported format round trips. Binding dependencies appear in +affected analysis under the declared policy; signal relationships are not mislabeled +as runtime calls in callflow. Existing non-QML queries and exports stay equivalent. +Test JSON, HTML and supported knowledge/document exports; test optional graph-DB +serialization with isolated transports, not production services. Queries and +generated guidance describe the feature subset actually present. +Include questions about a C++ signal's declared receivers and emission sites, +QML signal-to-C++ slot paths, C++ signal-to-QML handlers, and C++ lookup/invocation +of QML members. Query/affected, JSON/export reload and MCP must preserve source +direction, `metadata.qt.bridge_direction` and scoped object provenance, and +distinguish direct calls from emitted signals, declared connections, +disconnect statements and property access. Explicitly exercise the consumer parts +of `REQ-QML-016-AC04` and `REQ-QML-017-AC04`; diagram visibility alone is insufficient. + +Publish a matrix with syntax/version fixtures, module forms, build/resource forms, +bridge evidence, incremental behavior, consumer formats and installation platforms. +Mark each cell verified, partial, unsupported or unverified with its exact test +evidence. Dynamic runtime lookup, plugin loading, uncertain context objects and Qt SDK types +remain explicit limitations unless separately implemented and verified. Document +CLI examples, dependency installation, diagnostic meanings and update migration. + +**Proposed focused and artifact commands.** MCP coverage must execute in a job with +the optional MCP dependency, not pass solely through skipped tests. + +```text +uv run --frozen python -X utf8 -m pytest tests/test_qml_consumers.py tests/test_qml_end_to_end.py tests/test_qt_signals_slots.py tests/test_qml_cpp_access.py tests/test_query_cli.py tests/test_query_mcp_direction.py tests/test_affected_cli.py tests/test_export.py tests/test_cli_export.py tests/test_callflow_html.py tests/test_serve.py tests/test_serve_http.py tests/test_wheel_packaging.py -q --tb=short +uv run --frozen python -m tools.skillgen --bless +uv run --frozen python -m tools.skillgen --check +uv run --frozen python -m tools.skillgen --audit-coverage +uv run --frozen python -m tools.skillgen --schema-singleton +uv run --frozen python -m tools.skillgen --monolith-roundtrip +uv run --frozen python -m tools.skillgen --always-on-roundtrip +``` + +**Exit gate and PR boundary.** Split consumer corrections from docs/generated +guidance when useful; each fixes a proven gap with regression evidence. Run the +complete upstream suite and relevant install/platform jobs on the final reviewed +SHA. Prepare small upstream PRs with the problem, supported subset, invariants, +test results and remaining gaps. Rebase or merge current upstream under repository +policy, rerun affected verification, and obtain maintainer review. Release only +with truthful capability and dependency notes; do not equate this plan's completion +with unrestricted QML runtime understanding or accepted upstream integration. + +## First executable task and rollout + +Start with INC-QML-00 on a focused branch from an agreed base containing the reviewed +foundation documents and current upstream `v8` changes. Preserve predecessor work +and record both the foundation revision and upstream source SHA. Keep foundation +policy/documentation changes distinct from parser production changes when preparing +review diffs; do not silently lose the instructions by branching only from a base +that lacks them. Its ready specification is: + +1. Review PR #1748 at its current recorded head and decide which portions can be + reused or supplemented without duplicating upstream work. Record gaps and + license/attribution implications; an open PR's own success claims are inputs to + verification, not local proof. If it merges meanwhile, refresh the upstream + baseline and turn later increments into focused gap PRs. +2. Reproduce the recorded baseline and inventory the existing extraction/ID/cache + contracts. Record the actual source SHA and environment instead of relying on + a floating version name. +3. Add a public synthetic parser corpus with a `Main.qml` containing a root object, + `id`, one property, signal and method, plus a nested object. Separate fixtures + cover aliases, inline components, import forms, JS scopes, CRLF/Unicode, + unfinished braces, and metadata. Keep expected facts and limitations explicit; + no private project sources. +4. Probe candidate parsers in isolated environments and record AST node/range and + recovery evidence. Test installed/offline invocation and unavailable-parser + behavior. Document the selected parser version, license, optional/default + dependency policy, API/ABI compatibility and supported platform evidence. + Accept source-fact ownership, stable identity, nonlossy event-site projection, + and the safe-update fallback design before production slices consume them. + These are reviewed design contracts; the fallback itself must be implemented + and tested with the first enabled update path. +5. Commit the parser ADR, fixture coverage and requirement/traceability updates as + a reviewable PR. No production dispatch changes are required to accept the + spike. Failed packaging or syntax gates return to the decision; they do not + become undocumented extraction heuristics. +6. After review, implement INC-QML-01 as the next PR with minimal declaration extraction + and an explicitly bounded support claim. Expand the subset one increment at a + time. Keep each later capability disabled, unresolved or accurately documented + until its acceptance cases and consumer behavior are verified. + +For every release candidate, validate a fresh install and an existing graph/cache +upgrade. An incompatible fact or parser contract requires a reviewed cache +invalidation/migration note. Keep prior valid graphs recoverable and preserve the +upstream semantic layer. A feature is ready for wider rollout when its advertised +matrix has evidence; remaining unsupported runtime behavior is part of the public +contract, not a reason to fabricate relationships. + +INC-QML-00 through INC-QML-07 are complete within the accepted static profile. +Implementation and validation evidence are recorded in IMPLEMENTATION.md and +VALIDATION.md. Upstream submission/maintainer acceptance is a separate action. + + + + +## INC-QML-04 review and continuation + +Retain INC-QML-00..INC-QML-07 and all existing acceptance IDs. INC-QML-04a includes original-byte +annotation normalization and final canonical ID lookup, after a production fixture +exposed an omitted invokable. INC-QML-04c includes exact loader/owner scope and supplied +provider evidence; QML id/objectName, dynamic URLs, duplicate providers, revised +members and unsupported foreign mappings remain explicit boundaries. Reader/index +foundations may ship internally in INC-QML-04; public admission is still INC-QML-05. + +Within the initial profile, INC-QML-05 activates +literal metadata and bridge joins; INC-QML-06a proves mutation parity and INC-QML-06b proves +parser/configuration/ignore compatibility. INC-QML-07a covers consumer proof and +INC-QML-07b covers hosted packaging/documentation evidence. +INC-QML-07 is the intended completion of this documented static scope. If a promised +criterion cannot close there, record its gap and a required follow-on increment; +dynamic runtime/plugin/widget extensions are separate future scope. + + + + +## INC-QML-05 review + +Public discovery, dispatch and root forwarding now admit exact CMakeLists.txt, +.cmake, .pro/.pri, .qrc and .qmltypes. Module membership joins declarative native +providers; source/generated conflict diagnostics retain both origins. Public +facade regressions exposed generic namespace canonicalization merging independent +metadata declarations and resource occurrences; versioned Qt/QML source facts now +retain their original identities. Duplicate providers/aliases remain ambiguous. + +INC-QML-06 retains ignore/configuration refresh and last-provider cleanup obligations. +No extra top-level increment is needed. INC-QML-07 remains the final documented static +scope gate, with runtime behavior explicitly outside the supported profile. + + + + +## INC-QML-06 review + +Accepted Qt source/provider/configuration changes conservatively refresh the live +accepted code corpus. Ordered import roots reach both production QML resolver +passes; parser/fact/policy and ignore configuration stamps commit only after +successful graph and manifest publication. Last-provider deletion clears stale +facts and checkpoints the remaining non-Qt corpus. Watch reloads ignore rules +and schedules their changes. Explicit native worker cache policy avoids obsolete +Qt canonical declarations without changing ordinary C++ portable caching. Native +Qt subfolder updates reject unsafe scope-ID rebasing before publication. + +All remaining consumer/export/MCP, guidance and fresh hosted proof obligations +fit INC-QML-07a/INC-QML-07b. No additional top-level increment is currently required. A bounded +static analysis profile is the completion claim; runtime-generated behavior stays +explicitly unsupported rather than acquiring guessed links. + + + + +## INC-QML-07 implementation review + +INC-QML-07a propagates affected +Qt occurrence dependencies to their proven owners (including canonical C++ header/ +implementation pairs), retains source/event evidence in HTML and path output, and +keeps decoded semantic search bounded and identical through CLI and optional MCP. +Database transports retain exact metadata and logical endpoints; reserved field +collisions reject before file publication or driver creation. Export omissions +and report source-coverage limits are explicit in EXPORT_MATRIX.md. + +INC-QML-07b updates the authoritative assistant fragments and all generated artifacts, +executes real rendered publication safety/configuration examples, and adds installed +native CMake/resource bridge smoke. Production regression coverage includes +normal-QML-edit/manual/watch parity and no-change preservation evidence for the +existing REQ-QML-011 criteria. All work fits the existing INC-QML-07a/INC-QML-07b packages. + +INC-QML-07 is the completion gate for the initial static profile; each applicable +gate must pass before closure. Runtime-created registrations/objects, computed lookup, +framework/plugin internals, arbitrary build execution, legacy Qt profiles and +additional platform architectures would require separate agreed increments. +Completion here prepares reviewable fork PRs; upstream maintainer acceptance and +an actual upstream merge remain separate delivery actions. + + + + +## Final INC-QML-07 acceptance review + +All68 existing acceptance criteria have executed evidence for the declared bounded +profile. Real manual/watch edits cover native event changes/removal and reverse +QML member/objectName changes used by unchanged C++; no-change updates retain +accepted facts. Clean installed optional/core wheel smoke and the twelve hosted +OS/Python artifact lanes pass, alongside four full Ubuntu source lanes. Source +policy checks retain their frozen Git history and all generated guidance guards. + +No additional increment was required to close the agreed initial Qt6/QML scope. +INC-QML-07 completes the fork implementation and reviewable delivery preparation. +Runtime-generated registrations/objects, computed targets, arbitrary build/plugin +execution, additional Qt/platform profiles and live database systems remain +explicit limits or separately agreed future work. Upstream Graphify acquires this +implementation when its maintainers accept and merge the change; no upstream +merge, package publication or Qt application execution is claimed here. + + + +## INC-QML-08 — Installed-project adoption hardening + +**Status: Implemented; locally complete for the bounded Windows source and +installed profile.** Owner: Qt/QML integration maintainer. All three work packages +have passing local acceptance evidence. Wider hosted-platform delivery remains +unverified; baseline full-suite/type exceptions are recorded separately. +Disjoint source ownership and one integration owner govern concurrent work. +Prerequisite: the saved INC-QML-07 implementation and its revision-specific proof. +Adoption trials found source forms not represented by the accepted public corpus: +project-relative qmake expansion and conditional/irrelevant build statements, +native declaration/macro parse failures, typed factory/member context providers, +and context-provider signal/child-service chains. A passing installed synthetic +fixture does not establish whole-project compatibility. Record only generic public +reproductions and redacted conclusions; retain project evidence outside the public +repository. Local syntax proof does not establish the full adoption profile. + +| Package | Scope and acceptance | Dependency and exit | +| --- | --- | --- | +| INC-QML-08a | Minimize source/build failures; support agreed valid declaration/macro forms and bounded qmake path facts; REQ-QML-018-AC01/AC02, safety portions of AC06 | After INC-QML-07. Exit with public hand-checked success/malformed/uncertain fixtures, original-byte spans and agreed diagnostic/publication policy | +| INC-QML-08b | Resolve statically typed factory/member context providers and typed child-service API/signal chains; REQ-QML-018-AC03/AC04, safety portions of AC06 | Reproductions may proceed alongside 08a; resolved joins depend on its accepted declaration/type contracts. Exit with canonical scoped endpoints and persisted consumer evidence, including negative controls | +| INC-QML-08c | Exercise the combined profile through a clean installed optional wheel and production updates; REQ-QML-018-AC05/AC06 | After 08a/08b. Exit with whole-project/safe-subroot source/artifact parity, failure retention/recovery, mutation/idempotency and affected platform/consumer evidence | + +INC-QML-08a's qmake/header checkpoint used AST schema 11 / Qt policy 16. +The final combined profile uses AST schema 12 / Qt policy 18. INC-QML-08b and +INC-QML-29–38 complete declared-provider, consumer and publication corrections; +INC-QML-08c verifies the frozen source payload in a fresh installed wheel and +through the actual CLI. Exact evidence and platform limits belong to +[final validation](VALIDATION.md#final-adoption-delivery). REQ-QML-018 is locally +verified for this profile; it is not Verified across the full declared matrix. + +Before code changes, make each suspected product failure a public minimal +reproduction at its lowest faithful parser, reader or resolver boundary. Separate +valid syntax unsupported by the adapter from malformed input and unresolved +relationships. Do not bypass syntax or publication guards to obtain a graph. +For native failures record the smallest declaration/macro shape, parser version, +expected AST/facts and original byte ranges; do not presume file size is the cause. +Initial focused cases are empty-brace parameter defaults and `Q_UNUSED` use without +a caller semicolon. Those cases and numeric digit separators now retain original +byte spans and later signal ownership through the source parser/facade, with +malformed controls rejected. The focused local run has 63 passed. Same-package +cache/schema and analysis-epoch upgrade regressions pass, and the reviewed optional +wheel retains the bounded native source facts through installed consumers; exact +commands and artifact identity are in [validation](VALIDATION.md#inc-qml-08a-native-source-compatibility). Other +adoption patterns retain their own planned or unverified status. +Add regression coverage before the correction, then exercise the actual facade, +installed CLI and persistence boundary. Root/import and scope ownership remain +explicit; scanning a subfolder cannot invent missing project context or rebase +canonical identities unsafely. + +Native-header classification is implemented under REQ-QML-018-AC07 within +INC-QML-08a. The following discovery contract guided its characterization. A header selected as C can omit valid C++ references or declarations +even when direct C++ parsing succeeds. Before changing admission, define a stable +requirement and a bounded classifier contract with public C++ header positives, +plain-C and ambiguous-header controls, original-byte spans, actual facade and +installed-update evidence, and diagnostic/failure preservation. This assessment +does not enlarge the locally verified syntax criterion or claim a classifier fix. + +The qmake extension is bounded path interpretation, not a qmake interpreter. +For example, `QML_IMPORT_PATH += $$PWD/ui/qml` is a generic source shape to cover. +Use the current parsed file's directory for an accepted `$$PWD` prefix, preserve +ordered literal entries and accepted-root/ignore limits, and never expand arbitrary +variables, conditions, functions or includes by running qmake. Classify statements +by whether they can affect required analysis facts: recognized unrelated build +options can retain a coverage note; an unknown branch changing module, source or +provider facts stays uncertain/incomplete under the reviewed publication policy. + +Qt 6.8 distinguishes [Qt Creator `QML_IMPORT_PATH` hints from build-time `QMLPATHS`](https://doc.qt.io/qt-6.8/qmake-variable-reference.html#qml-import-path); +neither automatically establishes an application's runtime search path. Preserve +those roles and [current-file `PWD` provenance](https://doc.qt.io/qt-6.8/qmake-variable-reference.html#pwd). +Before enabling lookup, document precedence and the explicit accepted analysis-root +policy; do not silently promote a tool hint into engine evidence or expand the corpus. + +Context-provider support follows declared API types and accepted canonical owners. +Generic cases include `setContextProperty("backend", factory.makeBackend())`, +`backend.service.refresh()` and `backend.service.ready.connect(handler)`. +Accept a factory/member chain only with bounded unambiguous return/property type, +context and source evidence. Type evidence identifies a declared API; it does not +prove a runtime allocation, concrete subclass, getter result or delivery order. +Test duplicate providers, unresolved returns, conditional setup, shadowing, cycles +and chain limits; all retain uncertainty rather than acquiring guessed links. +Preserve distinct source sites for calls, subscriptions and handlers through +serialization, search, path and affected consumers. Fact/API/cache policy changes +need the architecture/design/error/assistant documentation impact review before +public admission, with original INC-QML-00–INC-QML-07 contracts preserved. + +The proposed verification matrix comprises direct extractor/reader/resolver tests, +facade build/JSON reload and consumers, installed optional/core behavior, whole +project and configured application subroot, metadata/provider/child-member/signal +mutation and removal, cold/warm/manual/watch parity, and actual failure at each +changed persistence boundary. Include a corrected retry and no-change repetition. +Record exact commands and new head/base/tested-checkout evidence when executed; +no proposed test path or previous hosted lane counts as new acceptance evidence. +Reverify the declared OS/Python installation lanes and affected generic C++/JS +regressions proportionately to changed parser/package/shared contracts. + +REQ-QML-018 has seven individually assigned locally verified criteria in +[traceability](../../tests/TRACEABILITY.md#planned-adoption-criteria). INC-QML-08 is +complete only after each independently passes and public support limits match the +expanded installed profile. It does not authorize project execution, arbitrary +runtime dataflow, an unrestricted build interpreter, upstream merging or package +publication. Newly observed cases require the same review and a versioned plan +update rather than a silent widening of verified support. + +A qualified application-subroot result may establish only its explicitly accepted +facts while whole-project build forms or factory/service relationships remain +unsupported. Record that scope and its remaining coverage; do not close REQ-QML-018 or +claim completed adoption from the successful subset or the focused native fix. + +The native correction preserves source authority through parser-only compatibility +normalization. AST cache schema 6 retires pre-correction syntax records even when +the package version is unchanged; Qt policy epoch 2 refreshes unchanged native +inputs under the updated analysis contract. Executed upgrade regressions prove +stale syntax/epoch retirement, failure retention and ordinary-C++ warm-cache +preservation for this native slice. The combined adoption safety gates still need +their metadata/provider/service and installed-update evidence. +The remaining qmake/provider/service and combined installed-profile work belongs +to INC-QML-08a/INC-QML-08b/INC-QML-08c. INC-QML-09 independently corrects HTML view +preparation; it does not expand or close the adoption scope. + +## INC-QML-09 — Readable large-graph HTML export + +**Status: Locally verified; reviewed installed artifact checked.** Owner: HTML export +maintainer, coordinated with the Qt/QML integration maintainer. Acceptance: +REQ-QML-019-AC01–AC04. Prerequisite: the accepted canonical graph and existing +HTML/community/export contracts; this increment does not depend on unfinished +INC-QML-08 metadata or provider work. + +The reported artifact can render successfully while presenting an unreadable +full graph. The default large-graph aggregate path also cannot construct a useful +view from missing communities, and an incomplete partition can discard members. +Missing labels leave the legend empty. Correct the owning view-preparation path +rather than increasing the node limit to force a full graph into the browser. + +Keep the correction HTML-specific. Validate that a supplied large-graph partition +covers every accepted graph node once with no foreign/duplicate members. For a +missing or invalid partition, invoke the existing local clustering interface on +a view copy; preserve the canonical graph and analysis files. Preserve applicable +supplied labels, fill absent labels through the existing hub-based labeling, and +discard labels attached to a replaced partition. The aggregate artifact must +show consistent group labels/counts and state which source details it omits. +No API, build hook or corpus code runs during this preparation. + +Select All starts checked. Construct visualization datasets with all exported +view nodes and edges active before network layout. For large source graphs, +these are the complete labeled aggregate communities within the supported cap, +not a forced full-source view. INC-QML-16 removes the former optional top-ten +Overview selection. Community labels describe inferred graph structure without +asserting authoritative architectural modules. Keep the complete exported metadata in +RAW and retain community filters, search, Select All and Select None. View choices +remain temporary; saved camera/filter persistence is outside this increment. + +The CLI honors actual written/skipped/failed results and selects analysis metadata +next to an explicitly chosen graph. Unrecoverable grouping reports +`HTML_GROUPING_INVALID`, a skipped/unusable aggregate reports +`HTML_VIEW_UNAVAILABLE`, and publication failure reports `HTML_VIEW_FAILED`. +Each returns a nonzero outcome, retains prior valid files and omits the +written-file success message. Many isolated groups can still exceed the supported +aggregate limit; retain a bounded failure with focused-graph guidance rather than +inventing group membership or bypassing the cap. Corrected retry and +unchanged-input repetition are acceptance gates. + +Use public minimal graphs to cover above-limit exports with missing/empty, +partial, duplicate and foreign-node partitions; supplied/missing/stale labels; +direct exporter and actual CLI entry points; graph immutability; many isolated +groups; and forced clustering/publication/skip failure. Exercise emitted HTML payload and script with +the existing DOM-harness style and preserve unrelated-language, escaping and +Qt/QML source-detail regressions. Initial-view tests must verify a checked Select +All and complete exported-view datasets before layout, community filters, +search/all/none controls and preservation of +the complete RAW payload. Record executed harness coverage accurately; +it does not establish a browser visit. A browser inspection that cannot run is +an explicit gap rather than inferred from static payload or screenshot context. + +Exit only when every REQ-QML-019 criterion has exact production regression +evidence, the complete diff preserves graph/persistence contracts, affected +documentation and traceability agree, and modularity/compatibility checks pass. +New installed-artifact or hosted evidence must identify the reviewed source, +artifact and checkout revisions; previous INC-QML-07 lanes do not verify this +consumer correction. No speculative dependency-loader or unrelated renderer +enhancement is part of this increment. + +REQ-QML-019 retains its first three acceptance IDs and adds AC04, bringing the +canonical catalog to nineteen requirements and seventy-eight criteria. The +current selection policy updates the existing AC04 without adding an ID. +INC-QML-16 owns the later Overview removal and camera interaction. The unfinished +qmake, provider and header-classification work remains +within the separately recorded INC-QML-08 adoption scope. + +The INC-QML-09 review retains the remaining INC-QML-08 source/metadata/provider +work. No additional top-level increment is needed for the bounded HTML grouping +contract at its recorded revision. Personal saved views are separate future work; current +camera, filters and search remain temporary. Executed exporter/script evidence +belongs to traceability and validation, with new browser/platform runs explicit. + +## INC-QML-10 — Native Qt source ownership correction + +**Status: Locally verified source/context correction and reviewed installed artifact.** Owner: Qt native +integration maintainer. This focused source +correction is separate from INC-QML-09's HTML grouping and selection policy. +Affected acceptance: REQ-QML-008-AC02, REQ-QML-016-AC01/AC04 and, where the same +source-owner boundary applies, REQ-QML-017-AC02/AC04. Existing identifiers remain +authoritative; this correction does not introduce a competing requirement scheme. + +An accepted canonical method can carry its declaration in a header and its +definition-file/location in an implementation. Native Qt mapping must consume +that exact provenance and accepted class ownership rather than leaving the +implementation's member/emission facts disconnected or choosing a class by name. +The corrected lookup separates forward class declarations from distinct complete +class definitions. Retain forward facts and IDs, but authorize +binding only from a uniquely evidenced complete definition. Add the bounded +`is_definition` class field from AST body presence; duplicate complete definitions +remain ambiguous, and missing authority does not become a guessed endpoint. +Public paired-source regressions must reproduce the missing ownership and check +ambiguous, wrong-file/line and unsupported-owner controls. Graph construction, +serialization/reload and aggregation retain established source-owned edges; +genuinely unresolved endpoints retain their uncertainty. + +Disjoint owners are canonical mapping in `qt_cpp_mapping.py`, class transport +and enrichment in `qt_cpp_exposure.py`, and complete-class endpoint eligibility in +`qt_event_index.py`. Scratch orchestration, source facts and graph/cache writers +retain their existing ownership; no generic IDs are replaced or borrowed nodes +mutated. Exact `definition_file`/`definition_location`, callable identity and +accepted class containment jointly authorize cross-file method binding. A name +match or a complete-class flag alone is insufficient. + +Accepted unchanged contexts preserve the class producer's exact source file and +original span alongside ID/name/definition authority. Dropping those fields gave +a borrowed representation a different deduplication key from a fresh record of +the same complete body, falsely creating competing definitions. Preserve the +body identity through borrowing and count identical accepted bodies once without +mutating context inputs. Distinct bodies and unsupported provenance retain their +ambiguity/unavailable status. The current direct/pipeline/build/reload context +regressions, final broad suite and reviewed installed-wheel proof pass locally. + +The correction requires same-version Qt policy epoch 3 invalidation while AST +cache schema 6 remains unchanged. Prove cold/warm and full/incremental parity, +stale-fact removal and prior-graph retention on failure. Include actual member, +emission, reverse-access and affected/serialized ownership paths; controls cover +forward-only classes, duplicate complete definitions, wrong definition file/line, +namespace/signature collisions and missing canonical evidence. Preserve generic +C++ and unchanged plain-C++ cache behavior. A successful fix does not imply that +all isolated Qt nodes can or should be connected. +Earlier proof includes 78 focused ownership/upgrade cases, 944 broad cases and +an intermediate same-version installed artifact. Current focused context/ +ownership/upgrade proof passes 88 cases; the final broad suite passes 954 cases, +with seven skips and one existing warning. Exact commands, skips, source tree +and wheel identity are in [validation](VALIDATION.md#inc-qml-10-native-source-ownership). +The bounded ordinary-method and accepted-context correction is locally complete; +remaining INC-QML-08 metadata/provider/header-admission gaps remain separate. +Review the plan after the source audit and record any additional independently +owned defect before adding implementation scope. + +## INC-QML-11 — Inherited Qt signal endpoint lookup + +**Status: Implemented; locally complete for the bounded source/update and reviewed installed profile.** Owner: Qt native event +maintainer. Acceptance: existing REQ-QML-016-AC01/AC04. Dependency: INC-QML-10's +complete-class authority and canonical member ownership. No requirement or +acceptance ID is added. + +Before INC-QML-11, `QtEventIndex.inherits()` traversed declared ancestors recursively, +while `member()` searched only the immediate bases when a class lacked its own member. +A source-established grandparent signal could pass type compatibility +without supplying its declared endpoint. That endpoint lookup defect was +separate from canonical method ownership and from genuinely dynamic or +unsupported types. + +First reproduce a public three-level class fixture at the actual event resolver +and facade/build/reload boundary. Then extend ancestor member lookup through +accepted complete-class evidence with bounded, cycle-safe traversal, preserving +canonical declaration IDs and existing role, signature, visibility and ambiguity +rules. Check direct-base regressions, missing/ambiguous complete classes, +conflicting ancestor signals and repeated paths to the same declaration. Do not +infer compiler conversions, unknown inheritance, runtime delivery or links merely +because nodes are isolated. + +Exit requires the declared grandparent endpoint and source emission/connection +mechanisms to survive export/reload/query/affected, with no invented direct +receiver call. Verify base/signal edits and removal across cold/full/incremental +results, stale-edge cleanup, retained prior products on failure and corrected +retry. Review compatibility-fingerprint impact before admission and use a +reviewed installed artifact for production proof. Keep exact unresolved cases +and broader runtime/platform limitations visible. The new inherited suites supply +their own endpoint and lifecycle evidence; INC-QML-10 counts do not verify this +expanded profile. Source-owned base access also gates external member-pointer +lookup. Legacy meta-object lookup and emission retain their own access mechanism; +compiler conversions or friend/protected context are not inferred. Qt policy 13 +invalidates prior derived facts. + +Exit review found the distinct explicit-emission admission gap now assigned to +INC-QML-28. Its correction follows without enlarging this endpoint traversal scope. + +## INC-QML-12 — Out-of-line constructor canonical ownership + +**Status: Locally complete for the bounded singleton profile.** Owner: generic C++ extraction/ +canonicalization maintainer, coordinated with the Qt native integration +maintainer. Acceptance: existing REQ-QML-008-AC02, REQ-QML-016-AC01/AC04 and +REQ-QML-017-AC02/AC04. Dependency: INC-QML-10's exact native ownership guard. +INC-QML-11's inherited endpoint lookup remains independently owned. + +A public source shape such as `Backend::Backend(QObject *parent) : QObject(parent) +{ emit changed(); }` retains an accepted generic callable ID and definition +file/line, but its containment can point to an implicit generic placeholder class +instead of the accepted complete header class. The strict Qt mapper therefore +leaves emission/access ownership unresolved. This is a generic constructor +canonicalization gap, not permission for native mapping to invent a class owner +or a regression of the proven ordinary-method correction. + +Characterize the actual generic producer and canonicalization boundary before +changing it. Establish the correct constructor-to-complete-class containment +using accepted source/provenance and preserve callable IDs, original spans and +unrelated generic relationships. Then exercise the existing strict native mapper +against the corrected evidence. Scope public fixtures to ordinary and +parameterized out-of-line constructors, with unsupported delegation/overload controls and paired namespace, +duplicate-class, conflicting-parent and missing/foreign-proof controls. A +constructor name, initializer expression or matching label alone is insufficient. + +Exit requires source-owned emission and supported QML-access sites to retain the +canonical constructor/class through actual facade, publication/reload, query and +affected paths, without inferred runtime delivery. Test constructor/parameter/ +base/ownership edits and removal, cold/full/incremental parity, stale-edge cleanup, +failure preservation and corrected retry. Review generic C++ compatibility and +analysis-epoch/cache impact before admission, then record a reviewed installed +artifact and exact commands. AST schema 7 and Qt policy 5 retire stale same-version +producer and derived facts. Preserve genuinely unsupported +constructors and dynamic QML targets as explicit unresolved cases. +Final related verification passes 1,044 cases with seven documented skips; +reviewed installed source/artifact and failure/recovery proof are recorded in +[validation](VALIDATION.md). Exact generic overload identity remains INC-QML-15; +browser appearance, other-platform and new hosted evidence remain separate gaps. + +## INC-QML-13 — Source-site links and community edge counts + +Status: **Locally complete for the recorded source and emitted-script boundaries**. Acceptance: REQ-QML-008-AC02, +REQ-QML-010-AC02/AC04, REQ-QML-011-AC03/AC04, REQ-QML-012-AC02 and +REQ-QML-019-AC02. Dependency: INC-QML-10 canonical source-provenance mapping. +INC-QML-12 independently corrects constructor containment; INC-QML-11 remains +planned. No new requirement or criterion identity is introduced. + +A Qt-admitted implementation can belong to an ordinary utility class whose +header has no Qt markers. Its accepted canonical callable already identifies +the member occurrence, while absent Qt class authority leaves the overlay +without containment. Preserve a source-site link to that unique callable +without admitting unrelated headers, establishing QObject roles, guessing a +class or inventing runtime calls. Missing, conflicting and foreign callable +evidence remains unlinked. Canonical class containment remains preferred. +When a callable owner is unavailable, retain actual file-to-occurrence containment +only from a unique accepted in-corpus source file. Do not populate a callable or +class owner from that file link. Explicit fresh AST identities and persisted +context provenance authorize the file role; conflicting, foreign or incorrectly +typed candidates cannot create an edge. + +Community aggregation omits internal source edges from its plotted meta-graph. +A community can therefore show zero neighboring communities while its members +have many internal links. Publish internal and external source-edge counts and +label neighboring-community degree explicitly. Distinguish that closed group +from a truly unlinked source group; never fabricate inter-community edges. + +Ownership: the integration maintainer owns native member/source ownership, policy invalidation, +requirements, documentation and installed proof; the viewer owner changes only +aggregate projection and inspector regression tests; the constructor owner +changes generic canonicalization and its tests independently. + +Acceptance matrix: exact canonical utility-method mapping and reload; absent +class authority without exposure; wrong/ambiguous callable rejection; unchanged +unresolved metadata with truthful file context; actual file IDs, fresh/context +provenance, missing/foreign/ambiguous/non-file rejection; unchanged +borrowed inputs; source spans and stable unrelated identities; cold/warm and +manual-update parity with stale-link removal; failed refresh retaining graph, +manifest and analysis state. Viewer fixtures cover closed connected groups, +true isolates, external weighted links and internal self-loops, full payload +counts, emitted inspector behavior, escaping and source-graph immutability. +Successful analysis adds no new diagnostic; unresolved semantic targets retain +the existing status/reason. Parser/transport/publication failures use existing +rejection and retention contracts; there is no new persistence boundary. + +Exit: focused production regressions fail before and pass after correction, +required related compatibility checks pass, a reviewed wheel reproduces the +source behavior, and the installed code-only consumer retains truthful counts +and links. Record commands, skipped lanes and remaining source-adoption gaps +without publishing private corpus content. Review the plan after completion. +The six focused production suites pass 90 cases; the final related suite passes +1,044 cases. The reviewed installed artifact reproduces file containment, +zero isolated Qt facts in the accepted adoption scope, truthful aggregate counts, +malformed-input retention and corrected retry. These facts do not establish +native runtime targets or close INC-QML-08/11/14/15. See +[validation](VALIDATION.md) for exact artifact identity, commands and exclusions. + +## INC-QML-14 — Explicit project membership relationships + +Status: **Locally complete; Verified within the bounded static source profile**. +Acceptance: REQ-QML-020-AC01–AC03, +with compatibility checks under REQ-QML-009-AC01/AC03, REQ-QML-010-AC02/AC03 and +REQ-QML-013-AC01/AC03. Dependencies: accepted QtProjectIndex module/resource lookup +and source endpoint identities. The current source-ownership correction does +not depend on this projection enhancement. + +Plan review identifies a separate display boundary: metadata source/resource +facts can resolve their targets in the per-run index while their only published +relationship is containment by the metadata file. A closed metadata community +therefore has true internal links but no displayed route to the declared source +files. Project accepted membership evidence through a focused resolver seam, +retaining literal scope, canonical identity and uncertainty. This is static source +membership; a source package declaration does not establish a runtime import, +architectural dependency or an evaluated build branch. + +**Scope and ownership.** `graphify/qt_project_membership.py` owns +`resolve_project_memberships(results, nodes, edges, *, root, project_index, +fresh_ast_ids=())` and +`tests/test_qt_project_membership.py` owns its public production regressions. +Keep each new handwritten file below 300 physical lines. The integration owner +owns the narrow hook in `qt_qml_pipeline.py`, policy-epoch/lifecycle handling and +actual CLI/watch/artifact checks. Existing parsers, indexes and persistence owners +retain their responsibilities. Comments explain accepted endpoint authority, +read-only borrowed input and uncertainty; they do not infer Qt execution. + +**Projection contract.** Each accepted declaration gets an independent +`membership_resolution` site with its original source/span, declaration identity, +source kind, module/alias context, bounded evidence/candidates and status/reason. +Declaration-to-site `contains` uses `qt_membership_site`. A uniquely resolved site +uses `references`, confidence `EXTRACTED`, and context `qt_project_source` for build +source membership or `qt_resource_membership` for a resource alias. The endpoint +is the actual accepted canonical file/component, never a reconstructed filename +ID. Separate sites retain repeated declarations and parallel mechanisms. No +target edge is emitted for missing, competing, conditional, generated or unsafe +targets. Raw declarations, borrowed dictionaries and existing lookup results stay +unchanged. Qt policy 6 refreshes prior same-version analysis; AST schema 7 and the +graph/manifest/checkpoint publication sequence stay unchanged. + +**Acceptance matrix; each criterion is locally verified in its bounded profile.** +Exact test assignments and executed outcomes are in +[traceability](../../tests/TRACEABILITY.md#project-membership-projection) and +[validation](VALIDATION.md#inc-qml-14-membership-projection). + +| Criterion | Success and boundary evidence | Rejection, state and failure evidence | +| --- | --- | --- | +| REQ-QML-020-AC01 | Actual CMake/qmake literals and qrc aliases reach unique canonical file/component targets through independent sites; direction, context, spans and confidence survive build/JSON/query; existing module/loader results agree | Same-name accepted paths and repeated declarations stay distinct; source/context inputs remain immutable and lookup results are not changed by projection | +| REQ-QML-020-AC02 | Explicit site status/reason and bounded evidence expose unavailable membership | Missing, duplicate, conditional, generated and out-of-root targets add no target edge or read; malformed transport/metadata and forced join failures reach existing diagnostics/publication guards and retain prior products | +| REQ-QML-020-AC03 | Actual cold/warm, manual update and watch match clean builds after source/resource edit, rename, deletion and ambiguity; no-change repeat is idempotent | Stale edges retire, unrelated accepted identities remain stable, HTML reflects persisted memberships, policy-6 upgrade refreshes unchanged prior inputs, and failed refresh/repair/repeat preserve the normal lifecycle | + +Use public CMake, qmake and qrc fixtures with duplicate names, conditional paths, +generated/out-of-root targets, root relocation, parser/transport failure and +metadata-only/source removal updates. Verify actual build/JSON reload/query/HTML +output, cold/warm/manual parity, stale-edge cleanup and prior-output retention. +No corpus expansion, engine/build execution, synthetic architectural grouping +or guessed endpoint is permitted. Ordinary unresolved sites use existing +`QML-RESOLVE-001` coverage semantics without becoming parser errors. Unexpected +join/transport failures reach the existing `QML_RESOLUTION_FAILED` guard; no new +parser or graph writer is introduced. Policy 6 and unchanged AST schema 7 have +current source and reviewed installed-artifact evidence. + +The completed exit gate has exact production evidence for all three criteria, +related metadata/loader/native and unrelated-language regressions, and reviewed +installed-artifact confirmation. The final reviewed-wheel selection passes +1081 cases with seven documented skips and one existing warning in 135.53 seconds; +its 37 new membership cases include 26 source and 11 lifecycle/consumer cases. +The later focused direction expansion passes 39 cases in 9.50 seconds; its two +additional variants do not retroactively change that broad-run count. +The installed public CMake/qmake/qrc fixture has six resolved memberships, preserves +four durable products after forced malformed metadata, then repairs and repeats +successfully. These outcomes are scoped to the local static profile; no browser, +new hosted, other-platform or Qt runtime proof is claimed. No additional increment +was required by this review. INC-QML-08 remains partial and INC-QML-11/15 planned. + +## INC-QML-15 — Exact constructor-overload identity and location parity + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. Acceptance: existing +REQ-QML-008-AC02/AC03 and REQ-QML-011-AC01/AC04. Owner: generic C++ producer and +canonicalization maintainer. Dependency: characterized baseline overload IDs, +source locations, relationships and compatibility rules. No new requirement ID. + +Multiple constructor overloads can share one generic name-based ID. Baseline +node deduplication retains the first definition while ordinary containment edges +can retain different occurrence locations in cold and watch graphs. Added header +prototypes can invoke existing collision remapping; neither path proves exact +overload identity. Conservative native guards reject contradictory proof and +source-file links remain truthful. This limitation is separate from singleton +constructor ownership and the INC-QML-13 correction. + +Characterize overload declarations/definitions, inline and out-of-line forms, +delegation, signature changes and same-named namespace classes through production +facade/build/reload/manual/watch. Define stable identity, declaration merge, +original-span authority, dependency queries and existing-graph compatibility before +changing the producer. Preserve ambiguity when type/alias evidence is insufficient. +Do not assign one overload's callable or class to another source occurrence. + +Exit requires exact accepted overloads and their locations to agree across +cold/warm/full/incremental graphs, stale-edge removal, failure retention and +corrected retry, with generic call and unrelated-language regressions. Record +schema/policy migration, reviewed artifact proof and remaining unsupported forms. +The producer now assigns signature IDs before graph collapse. Supported builtin, +self and evidenced QObject signatures keep their identity when overload neighbors, +parameter names or defaults change. Unsupported type forms keep occurrence IDs; +they cannot authorize declaration/body joins. Source-size and containing-class +proof reject corrupted ranges. AST schema 10 and Qt policy 14 retire earlier IDs; +[D20](ARCHITECTURE.md#d20--constructor-signatures-authorize-identity-and-joins) +defines migration and recovery. Local exact-owner, lifecycle and peer regression +results are recorded in validation. Final reviewed installed proof passes for the bounded Windows profile. + +The exit review found a separate generic function-name omission for reference +returns. INC-QML-29 owns that producer correction; constructor signature admission +does not silently widen to arbitrary C++ type equivalence. + +## INC-QML-16 — Middle mouse navigation and Overview removal + +Status: **Locally complete for the emitted/installed HTML consumer profile**. +Native browser/device/platform interaction remains unverified. Acceptance: revised REQ-QML-019-AC04 and +REQ-QML-021-AC01–AC03. Dependency: the existing HTML viewer, its source-community +projection and pinned vis-network camera API. Owner: HTML exporter maintainer. + +The viewer starts with every exported community selected. The Overview button, +top-ten selection calculation and reset function are removed. Community filters, +Select All/None, search and inspector behavior remain available. Select All reflects the +actual loaded dataset, including nodes without community metadata; a partially +visible dataset is indeterminate even when every named community is checked. + +The [frozen camera helper](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/exporters/html_navigation.py) owned a private middle mouse gesture +injected after network construction. Holding the middle button and dragging pans +the camera in both axes using each movement's current zoom scale. Native wheel +zoom remains available. Pointer capture and window listeners keep outside moves +and release safe; release, cancellation, capture loss, blur and page exit restore +prior cursor/selection styles. Invalid input or camera failure ends the gesture +without publishing an invalid camera position. No graph, node position, filter, +selection, cache or persistent view state belongs to this controller. + +| Criterion | Success and boundary evidence | Rejection, state and failure evidence | +| --- | --- | --- | +| REQ-QML-019-AC04 | Full initial dataset; filters, Select All/None and hidden-result search; no Overview control or reset path | Partial and ungrouped selections report accurate checkbox state; graph and raw payload retain their identities | +| REQ-QML-021-AC01 | Actual emitted source/community script pans horizontally, vertically and diagonally at multiple scales; repeated/outside moves and visible instructions | Middle autoscroll and selection are suppressed; camera movement changes no nodes, edges or zoom | +| REQ-QML-021-AC02 | Release/cancel/capture loss/blur/page exit restore style and permit retry | Unrelated pointers, missing/failed capture, invalid coordinates/camera, overflow and API errors cannot leave an active or invalid gesture | +| REQ-QML-021-AC03 | Left/right/touch and native wheel paths, community filters, search and inspection remain available | Camera-only changes preserve raw/source graph bytes and filter/selection state; unavailable browser/platform proof stays explicit | + +Production-interface JavaScript regressions use a DOM/network boundary harness; +the production controller supplies the coordinate calculation. Run related +exporter/CLI regressions, lint/type checks, a reviewed wheel installation and the +installed HTML export. Bind proof to the exact source tree and wheel digest. +Regenerate the existing application HTML without re-extracting its unchanged +source graph. Browser appearance and other-platform interaction remain separate +system gaps unless actually executed. + +Diagnostic impact is ephemeral camera cleanup or a no-op on rejected input; no +new parser, writer, persistence boundary or diagnostic code is introduced. The +existing HTML publication failure policy continues to apply. The new module and +tests retain the 300-line ceiling; the existing HTML template's documented +810-line exception allows its narrow injection and removal. Plan review must +record any evidenced new follow-up without treating pending INC-QML-08/11/15 as +completed by this consumer increment. + +The exit review finds no additional increment necessary for this control change. +Sixty focused selection/navigation cases pass; the final exporter/CLI/membership +selection passes 231 cases, and seven additional consumer/reviewed-wheel cases +pass. Exact outcomes, reviewed source tree, wheel identity and installed-view +proof belong to [validation](VALIDATION.md#inc-qml-16-middle-mouse-navigation-and-overview-removal) +and traceability. No source schema or analysis policy changes. INC-QML-08 remains +partial and INC-QML-11/15 planned; camera persistence is outside this contract. + +## INC-QML-17 — Receiver-owned reflection and child lookup + +Status: **Implemented; bounded local source/artifact evidence, baseline contribution-gate failures retained**. Acceptance: +REQ-QML-017-AC02/AC04. Owner: reverse-access resolver maintainer. Dependency: +the current access index and public A10/A11 probes in the follow-up audit. + +Separate C++ QObject member lookup from QML lexical name lookup. Resolve reads, +writes and reflective calls only against the evidenced receiving object and +accepted type/inherited members. Restrict findChild to accepted receiver-relative +QObject parenting evidence, honoring recursive/direct-only options. Unknown +parenting, dynamic names/options and ambiguous trees remain unresolved. + +The acceptance matrix includes own/inherited/root members, absent child members, +sibling rejection, recursive grandchildren, direct-only rejection, duplicate names, +unsupported/reparented trees and original spans. Promote A10/A11 probes into normal +regressions. Verify directed/undirected reload, query/affected, cold/warm/manual/watch +parity and stale-edge removal after receiver/member/tree changes. Force a real +analysis/publication failure, preserve prior products and prove repair/retry. + +Exit requires all affected cases passing through production and reviewed installed +artifacts, existing QML lexical-expression behavior preserved, unrelated-language +regressions and current full contribution gates. Record diagnostic reasons and any +schema/policy invalidation before retaining old overlays. Review the plan again +after implementation; this increment does not close INC-QML-08/11/15/18/19. + +The implementation separates receiver member lookup from lexical lookup and +retains source construction evidence independently of visual-parent semantics. +Known QtQml/QtQuick QObject types and accepted source/native QObject base chains +authorize the bounded tree; unknown/custom parenting and templates do not. +Recursive/direct-only options, duplicate names, source proof corruption and +readonly writes have ordinary collected regressions. Real manual/watch member +and tree edits, policy refresh, parse/join/replacement retention and retry pass +eight lifecycle cases. Exact final source/artifact evidence remains in validation. +Exit review adds no new increment: broader API, provider, ancestor and overload +gaps already have separate recorded scope. INC-QML-18 follows without a pause. + +## INC-QML-18 — Lexical engine and provider identity + +Status: **Implemented; bounded local source/artifact evidence, baseline contribution-gate failures retained**. Acceptance: +REQ-QML-017-AC03/AC04, with REQ-QML-008 provider regressions. Owner: context-provider +integration maintainer. Dependency: characterize existing source-local declaration, +handle and assignment facts before changing transport or lookup ownership. + +Bind loads and context/initial-property providers to the exact engine/component +declaration and supported lexical lifetime. Same spelling in disjoint/nested blocks +must not merge engines. Preserve assignment, conditional and unknown-provider +uncertainty; do not execute source to determine runtime identity. + +The matrix includes one-engine success, disjoint/reused names, nested shadowing, +reassignment, conditional exposure, context aliases, multiple loads and unknown +ownership. Promote the A12 probe into ordinary regression collection. Verify +provider/load edits and deletion through cold/warm/full/manual/watch, JSON reload, +query and affected, with stale-edge removal, forced failure retention and retry. + +Exit includes exact spans and stable identities, compatibility/cache migration +where needed, diagnostic ownership, reviewed installed-wheel proof and full +contribution gates. Factory/member provider admission remains INC-QML-08; this +correction must not guess a provider merely to increase coverage. Review and update +the plan at exit; INC-QML-17 and INC-QML-19 have independent owners. + +The declaration index now owns engine, component, provider and handle identity. +Exact source declarations and bounded lexical lifetime replace function/name keys; +event endpoints transport the selected handle declaration independently. Missing, +reassigned, conditional or ambiguous identities cannot supply a binding. Provider +expiry and mixed initial-property rejection have collected source regressions. +Policy 8 invalidates prior derived Qt results at the same package version. + +Exit review assigns a further INC-QML-17 construction guard to INC-QML-19 +integration, where canonical native base identities become available: registered +native types require a proven non-widget QObject ancestry. Qt widget creation has a +different parent policy, so a Q_OBJECT marker alone cannot prove a child tree. +This remains the existing receiver acceptance scope. Included-header alias shadows +extend INC-QML-19's matrix; no new increment is required by these corrections. +Factory/member identity, inherited endpoints and generic overloads remain in +INC-QML-08/11/15. Exact artifact and lifecycle outcomes belong to validation. + +## INC-QML-19 — Native endpoint type and alias scope + +Status: **Implemented; bounded local source/artifact evidence, baseline contribution-gate failures retained**. Acceptance: +REQ-QML-008-AC01/AC03 and REQ-QML-016-AC01–AC04. Owners: native type/registration +integration and endpoint maintainers. Dependency: accepted +canonical declaration/type spelling and lexical alias characterization. + +Resolve member-pointer/emission endpoints and QML registrations using evidenced +type/alias declaration identity. +A local alias cannot select the shadowed global class. Support only proved alias +forms; rejected, cyclic, conditional, conflicting or unsupported aliases remain +unresolved. Characterize same-namespace unqualified types alongside qualified +controls without conflating conservative misses with false-positive targets. + +The matrix includes direct class and native-to-QML controls, local and namespace aliases, nested +shadowing, conflicting/cyclic aliases, ordinary functions, overloaded signals and +receiver compatibility. Promote A13 into normal regression collection. Preserve +emission/connect/disconnect distinctions, original spans and canonical endpoints +through build/reload/query/affected and cold/warm/manual/watch edits/removal. + +Exit requires failure retention/retry, policy/cache compatibility as applicable, +reviewed installed artifact and current full-suite/lint/type gates. Keep inherited +ancestor lookup in INC-QML-11 and overload identity in INC-QML-15. Review the plan +after completion; passing an alias fixture alone does not establish complete C++ +type analysis or Qt runtime equivalence. + +Source-local simple using/typedef and namespace aliases now bind to canonical +accepted classes in lexical scope. Comma-separated typedefs retain every shadow. +Explicit accepted-header include closure blocks an unavailable alias target from +falling back to a global class; it does not preprocess or resolve header targets. +Alias-only accepted headers participate in shared Qt invalidation. Conditional, +cyclic, conflicting, corrupted and unsupported forms keep no guessed endpoint. + +Canonical base names also complete the continuing INC-QML-17 construction guard: +only proven non-widget QObject ancestry can authorize a native child tree. +Source-defined QObject shadows and unknown/widget bases remain unavailable. +The bounded findChild QObject* filter likewise rejects lexical/native shadows. +Policy 9 refreshes derived facts; AST schema 7 and graph relationships remain +compatible. Exit review adds INC-QML-21 for reproduced same-file qualified class +identity collisions. Accepted-header positive type resolution +is explicitly excluded, and broader inheritance/overload/adoption scope remains +INC-QML-08/11/15. INC-QML-20 follows without a pause. + +## INC-QML-20 — Literal loader provenance coverage + +Status: **Implemented; bounded local source/artifact evidence, baseline contribution-gate failures retained**. Acceptance: +REQ-QML-017-AC01/AC04. Owner: native loader collector/integration maintainer. +Dependency: lexical engine identity contract from INC-QML-18 and current loader, +resource/module and root-handle interfaces. Characterization may proceed earlier. + +Admit literal QQmlApplicationEngine URL construction and QQmlComponent::loadUrl +followed by create through the same evidenced component/root provenance as the +existing engine.load and literal component-constructor paths. Preserve constructor +overload distinctions; nonliteral/unsupported URL/base arguments, conditional +loads, multiple/reassigned engines and unresolved creation remain uncertain. + +The matrix pairs both accepted routes with engine.load controls, computed URLs, +conflicting sources, ignored/out-of-root paths, malformed input and constructor/ +load argument boundaries. Promote the public loader probe into ordinary test +collection. Verify source spans, source/class/engine ownership and distinct loader +facts through build/reload/query/affected and cold/warm/manual/watch edits/removal. + +Exit includes stale-edge cleanup, actual failure retention and retry, existing +loader/module/resource regressions, reviewed installed artifact and current full +contribution gates. Document policy/cache invalidation as applicable. loadData, +setData, staged creation and other omitted families in the mechanism matrix are +outside this bounded increment until explicitly accepted. Review the plan at exit; +passing these two routes does not close all Qt/QML API gaps. + +Literal engine URL constructors and component loadUrl/create now retain +source-owned load facts. An engine-only component constructor supplies its exact +engine association without creating a second load. Loader/root overload, URL +wrapper, conditional and source-defined SDK-name rejection have collected tests; +context providers join only that established engine. Policy 10 refreshes derived +Qt facts; AST schema 7 and the persistence owner remain unchanged. + +Exit review retains INC-QML-21 and adds INC-QML-22 for static reflection shadows, +plus INC-QML-23 for the pre-existing Windows read-only replacement defect found +by full contribution testing. Exact local/package results and unchanged baseline +gate failures are recorded in validation. No hosted or other-platform proof is +claimed, and INC-QML-08/11/15 remain open. + +## INC-QML-21 — Qualified native class identities + +Status: **Complete for the locally verified bounded profile; full repository/system limitations remain explicit**. Acceptance: +REQ-QML-008-AC02, REQ-QML-016-AC01/AC04 and REQ-QML-017-AC02/AC04. +Owner: generic C++ identity/canonicalization maintainer. Dependency: characterize +the existing generic producer, native mapping and sanitation/export ID contracts. + +Distinct qualified classes with the same basename in one accepted source file +must retain separate canonical identities. A global Base and Public::Base +collide at the audited baseline, causing accepted class proof to become ambiguous. Preserve +forward/definition/header matching and reject actually conflicting definitions; +never reconstruct an identity in a downstream Qt resolver to bypass the producer. + +The matrix includes global/namespaced/nested same-basename definitions, references +before/after definitions, repeated forward declarations, separate-file controls, +unrelated C++/language regressions and original BOM/CRLF/Unicode spans. Verify +source/native containment, aliases, registration, event endpoints and construction +through build/sanitation/export/reload/query/affected and cold/warm/manual/watch. +Require stale-node/edge removal, compatibility/migration policy, actual failed +publication retention/retry and reviewed installed artifact evidence. + +At the INC-QML-20 checkpoint, colliding producer identities remained unresolved. +INC-QML-21 separates accepted qualified declarations at the producer and retains +conservative rejection for conflicting or insufficient declaration evidence. + +## INC-QML-22 — Static reflection API identity + +Status: **Complete for the locally verified bounded profile; full repository/system limitations remain explicit**. Acceptance: +REQ-QML-017-AC02/AC04. Owner: native access collector/type-authority maintainer. +Dependency: the source-local declaration/type authority introduced by INC-QML-18/19. + +Apply SDK class identity to QMetaObject::invokeMethod, QQmlProperty::read/write +and QQmlProperty handles. A local alias, global source class or namespace class +with the SDK name creates a wrong persisted QML target at the audited baseline. Preserve the +unshadowed SDK controls and exact receiver-owned access semantics. + +The original opt-in probe recorded twelve failing rejection cases and two passing +controls. The correction promotes those cases into ordinary regression collection. +Verify original spans, SDK-qualified and source-shadowed forms, handle lifetime, +directed/undirected reload, query/affected, cold/warm/manual/watch stale-edge +removal and failed publication/retry. Preserve unrelated C++ and native-event +contracts, refresh policy if necessary and prove a reviewed installed artifact. + +INC-QML-17 established receiver ownership. INC-QML-22 separately verifies static +API authority, including source declarations that shadow SDK spellings. + +## INC-QML-23 — Failed Windows replacement retention + +Status: **Complete for the locally verified bounded profile; full repository/system limitations remain explicit**. Acceptance: +REQ-QML-018-AC06, with REQ-QML-011-AC04 and REQ-QML-013-AC03 regression review. +Owner: shared atomic-write/persistence maintainer. Dependency: characterize the +existing rename/replace/cleanup ordering and caller-owned product publication. + +At the audited Windows baseline, replacing an existing read-only destination could +publish new bytes and then fail while deleting the displaced file. A reported failure +must preserve the prior accepted destination and leave no leaked temporary file. +Preserve the existing public writer interface and product publication ownership. + +The existing strict atomic-write test failed on the baseline and INC-QML-20 +revisions. Verify this shared boundary separately, using actual OS read-only +files, supported replace/rename fallback paths, cleanup failure and permission +recovery. Exercise manual/watch graph, manifest, stamp and cache retention, +corrected retry and repeat idempotency; include unrelated writer/platform +regressions and installed-artifact evidence. Document diagnostic and rollback +ordering and any unavoidable multi-product atomicity limits. + +At the INC-QML-20 checkpoint, injected loader replacement failures passed while +the real read-only OS boundary remained open. INC-QML-23 verifies individual +replacement retention; INC-QML-25 verifies coordinated product publication. + +## INC-QML-24 — Loader SDK declaration authority + +Status: **Complete for the locally verified bounded profile; full repository/system limitations remain explicit**. +Acceptance: REQ-QML-017-AC01/AC04. Owner: loader collector/type-authority maintainer. +Dependency: INC-QML-22's explicit external-SDK versus source-declaration lookup. + +An incomplete source declaration with a Qt SDK name must not authorize loader, +engine, URL-wrapper or root semantics merely because no canonical complete class +was admitted. At the audited baseline, engine constructor/load, component +constructor/loadUrl, engine association, view and QUrl/QString wrapper cases +retained false targets. The initial reproduction recorded eight passing external +SDK controls and eight failing source-forward controls at persisted target assertions. + +Apply the shared SDK authority contract at source positions. Preserve explicit +global SDK controls, accepted literal source provenance and conservative alias/ +forward uncertainty. Verify original spans, build/reload/query/affected, real +manual/watch forward-declaration edits/restoration/removal, failed publication +retention/retry and reviewed installed artifact. Refresh derived policy as needed. +Review plan at exit; source declarations are inputs, not automatically SDK proof. + +Included-header forward declarations are part of the same SDK-authority boundary. +Literal accepted include/provenance evidence must retain their shadows separately +from complete native target identity. Header-only edits require the same update +invalidation and restoration/removal tests; imported target support is not implied. + +## INC-QML-25 — Product publication completion and rollback + +Status: **Complete for the locally verified bounded profile; full repository/system limitations remain explicit**. +Acceptance: REQ-QML-018-AC06, REQ-QML-012-AC02 and REQ-QML-011-AC04. +Owner: CLI/watch publication integration maintainer. Dependency: INC-QML-23 +individual atomic replacement retention and existing product persistence owners. + +At the audited baseline, real read-only manifest or Qt analysis-stamp writes could +fail after graph bytes advanced while watch returned success. A successful update must complete every +required publication product. A rejected/failed publication must retain the prior +accepted graph and state, report failure, and permit corrected retry. Distinguish +successful source-cache facts from accepted published graph/state and document +that boundary precisely. + +Characterize manual/watch graph, manifest, root and Qt stamp ordering. Use actual +read-only destinations and injected staged-write, replacement, cleanup and rollback +failures; assert durable bytes, explicit caller results, diagnostics and owned +temporary cleanup. Preserve safe-root/symlink behavior and unrelated consumers. +Require repaired retry, repeat idempotency, cold rebuild agreement and reviewed +installed artifact. Define recovery evidence for a second OS failure during +rollback without hiding the integrity failure. Coordinate shared watch ownership +with INC-QML-26 and review the plan at exit. + +## INC-QML-26 — Watch removal of orphan reference nodes + +Status: **Complete for the locally verified bounded profile; full repository/system limitations remain explicit**. +Acceptance: REQ-QML-011-AC02/AC04 and REQ-QML-017-AC04. +Owner: incremental graph cleanup maintainer. Dependency: accepted source-node, +derived-edge and synthetic-reference ownership; publication integration INC-QML-25. + +At the audited baseline, changing SDK reflection to a source alias and restoring +it left an orphan generic QObject reference after watch that a clean graph lacked. +The accepted Qt targets agreed; full graph parity must also remove stale synthetic nodes. +Keep exact node identity/provenance and preserve references owned by unchanged +sources or other graph origins. + +Retain the strict SDK→alias→SDK lifecycle assertion. Characterize multiple source +owners, remaining live references, source deletion and generic-language controls +at the smallest faithful production boundary. Verify no stale node/edge after +manual/watch edits/restoration/removal, JSON reload, query/affected and normalized +cold/warm parity. Force publication failure/retry through the shared guards, +document migration if needed and prove a reviewed installed artifact. Review the +plan after correction; do not weaken parity to ignore unattributed nodes. + +## INC-QML-27 — Native property notification handlers + +Status: **Complete for the locally verified bounded profile; full repository/system limitations remain explicit**. +Acceptance: REQ-QML-007-AC01/AC03, REQ-QML-008-AC02/AC03 and +REQ-QML-017-AC03/AC04. Owner: QML/native member and expression-binding maintainers. +Dependency: accepted property/provider/accessor identity and existing signal joins. + +A registered native property must connect `onChanged` to its actual +accepted NOTIFY signal even when that signal has a different name. Bind only an +established property provider and unique accepted signal; absent, CONSTANT, +non-signal, conflicting or unavailable notification evidence supplies no target. +Retain original source spans, canonical native IDs and subscription direction. + +Explicit function/arrow handlers bind only their declared parameters. Legacy +block handlers retain their implicit signal-parameter behavior. An omitted formal +parameter must not hide a visible native property merely because it shares a +NOTIFY signal parameter's name. This adjacent binding correction belongs to the +same notification/parameter scope; it is not an unrelated refactoring. + +Characterize direct and Connections handlers, conventional/custom/shared NOTIFY, +multiple providers, parameter shadowing, unavailable modules and invalid accessor +evidence. Verify actual facade/build/JSON/query/affected behavior, cold/warm/manual/ +watch edits/restoration/removal, publication failure/retry and installed artifact. +Invalidate cached QML handler facts when their parameter-authority contract changes. +The [chapter review](EXPOSURE_CHAPTER_REVIEW.md) records the official semantic +checklist and conservative runtime exclusions. Review the plan at exit. + +## Follow-up exit review (INC-QML-21–27) + +All seven correction sources have local passing production regressions. Peer +review corrected capped namespace ambiguity, included SDK-name forwards, +non-OS rollback loss and unguarded publication setup diagnostics within their +owning increment scopes. No separate increment is needed for those corrections; +the shared acceptance obligations already cover them. No additional concrete gap +is confirmed at this checkpoint. Final reviewed wheel, contribution-gate limitations and graph +refresh results are recorded in validation; the bounded local exit is complete. INC-QML-08/11/15 retain their +separate broader adoption, inherited-signal and overload scope; runtime SDK +behavior, other-platform/native-device and new hosted proof remain unverified. + +## Remaining adoption delivery + +This delivery matrix was established before implementation. INC-QML-11 and +INC-QML-15 were reviewed and committed first, then INC-QML-08a. The accepted +provider/consumer corrections and INC-QML-08c evidence now complete the local +profile. Each reproduced defect generated a distinct correction increment; +INC-QML-28–38 are included in the final reviewed source and installed validation. + +| Scope / acceptance | Success and boundary matrix | Failure and recovery matrix | Ownership | +| --- | --- | --- | --- | +| INC-QML-11; REQ-QML-016-AC01/AC04 | Three-level and diamond signal inheritance, exact declaring member, shadowing, conflicting ancestors, cycles, unavailable classes, original spans; export/query/affected and full/update parity | Real publication failure preserves accepted products; signal/base edits, removal, repaired retry and repeat update | Native event agent: event index/resolution and new inherited-signal tests; integration owner: docs, epochs and publication integration | +| INC-QML-15; REQ-QML-008-AC02/AC03, REQ-QML-011-AC01/AC04 | Distinct constructor signatures; declaration/definition merge, inline/out-of-line/delegating and namespaced forms; exact original locations, rejected ambiguous type evidence and generic language controls | Signature edit/removal, cold/warm/manual/watch parity, failed publication and repaired retry; explicit identity migration | Generic C++ agent: constructor/producer identity and new overload tests; integration owner: shared cache/graph hooks and docs | +| INC-QML-08a; REQ-QML-018-AC01/AC02/AC06/AC07 | Current-file literal PWD prefixes, relevant versus unrelated qmake statements, separate import-hint/build/analysis roles; header classification assessment; source spans and no project execution | Conditional required facts, unsupported expansion, missing/corrupt metadata, root escape, retained products and retry | Metadata agent: qmake reader and new metadata tests; integration owner: discovery classification, admission and docs | +| INC-QML-08b; REQ-QML-018-AC03/AC04/AC06 | Declared factory return/member types, context providers and bounded child-service calls/subscriptions; exact scoped endpoints, lexical shadows, duplicate/conditional/unresolved/cyclic evidence | Metadata-only, C++-only and QML-only invalidation, transport rejection, failed resolution/publication and corrected retry | Integration owner: provider/member join scope until assigned disjoint modules | +| INC-QML-08c; REQ-QML-018-AC05/AC06 | Public combined Qt 6 CMake/qmake corpus through source and reviewed installed wheel, whole root and configured safe subroot, cold/warm/manual/watch consumers | Actual parser/resolver/publication failures; prior graph/checkpoint/cache retention, missing optional parser, bounded safe diagnostics, recovery and idempotency | Integration owner: installed acceptance, shared state, graph refresh, documentation, staging and commits | +| INC-QML-28; REQ-QML-016-AC01/AC04 | Explicit emit/Q_EMIT source occurrences survive a missing, renamed or removed declaration; only proven declarations supply signal endpoints | Header-only removal/restoration, full/watch equality, failed publication and retry; no fabricated delivery call | Native event agent after INC-QML-11 freezes: emission collector and new admission tests; integration owner: epochs and docs | + +Scoped Graphify queries locate owners and callers. Source bytes, production +interfaces and durable outputs establish correctness. No corpus code, Qt engine, +project build hook or plugin executes during analysis. New handwritten helpers +and tests retain the 300-line ceiling; touched legacy growth needs a measured +ownership and extraction rationale. Each exit reviews this plan and records any +confirmed additional gap without silently widening verified support. + +## INC-QML-28 — Explicit emission source admission + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-016-AC01/AC04. Owner: native event collector maintainer. +Dependency: accepted source-site ownership and INC-QML-11 inherited endpoint lookup. + +An explicit `emit` or `Q_EMIT` occurrence remains a source-backed event site when +its declared signal is unavailable. Renaming or removing the declaration removes +the prior endpoint edge and retains an unresolved reason at the occurrence. The +collector cannot use the current declaration inventory as permission to observe +the explicit source syntax. Ordinary bare calls still require existing signal +evidence; comments and strings cannot create event sites. + +The initial header-rename reproduction loses the unchanged emission occurrence +while connections become unavailable. Verify direct collector/facade/build/reload, +exact original bytes and canonical callable ownership, positive inherited signals, +missing/private/ambiguous targets, and both emission spellings. Exercise C++-only +and header-only edits, removal/restoration, cold/warm/manual/watch parity, failed +publication and corrected retry through installed consumers. Preserve ordinary +call behavior and do not infer immediate receiver delivery. Review the plan at exit. + +The final scanner uses accepted standalone executable AST call ranges, masked +original bytes and annotation override order. Annotation authority precedes a +same-spelled connect/disconnect API; rejected tokens cannot regain authority from +a textual prefix. Computed receivers retain the full annotated outer occurrence +and an unavailable endpoint, without misclassifying their nested factory calls. +Local regressions exclude each reproduced failure. Qt policy 15 refreshes derived +facts with AST schema 10 unchanged. Exit review adds no further scanner increment; +INC-QML-29 and the adoption consumer corrections remain separately assigned. + +## INC-QML-29 — Reference-return callable admission + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-008-AC02/AC03, REQ-QML-018-AC02/AC03/AC06 and +REQ-QML-011-AC01/AC04. Owner: generic C++ declarator producer maintainer. +Dependency: accepted C++ grammar, original source spans and canonical member join. + +A valid reference-return callable retains its generic declaration or definition +identity. The grammar's reference declarator contains a function declarator without +a named `declarator` field; the current name walker omits that callable. Qt member +facts then lack a canonical generic endpoint and correctly remain unresolved. +Follow the actual declarator structure with a bounded fallback; do not infer names +from unrelated children or broaden unsupported pointer, alias or runtime forms. + +Promote the public opt-in reproduction into ordinary regression coverage with the +correction. Verify lvalue/rvalue and qualified reference returns, ordinary value +and pointer controls, malformed/name-ambiguous rejection, exact BOM/CRLF/Unicode +spans, direct producer/facade/JSON/query/affected and typed-provider consumers. +Exercise C++-only edits, cold/warm/manual/watch parity, stale-edge removal, real +publication failure, retained products and repaired retry. Refresh producer and +derived cache epochs and prove the reviewed installed artifact. Review the plan +at exit and retain conservative unsupported API shapes. + +The eight-line producer fallback admits only a sole actual function declarator +beneath the reference wrapper. Ordinary value/pointer and qualified names remain +compatible; rvalue references remain generic callables but unsupported provider +shapes. Original const lvalue-reference spelling remains static API evidence, +without runtime conversion or invocation proof. Its new source tests and the +08b typed-provider tests are integrated together because their positive reference +profile shares the canonical endpoint boundary. AST schema 12 / Qt policy 17 +invalidate both new producer contracts in that same change. Exit review found the +independent affected-query omission assigned to INC-QML-30; no additional generic +callable increment is confirmed. + +## INC-QML-30 — Affected-query logical edge direction + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-018-AC04/AC06, REQ-QML-017-AC04 and REQ-QML-011-AC01/AC04. +Owner: dependency consumer maintainer. Dependency: persisted canonical source +occurrences and direction attributes, with existing Qt source-owner promotion. + +Affected queries follow accepted logical dependency direction in both directed and +undirected graphs. An undirected NetworkX tuple may place the callee first merely +because it was inserted first; valid `_src`/`_tgt` evidence governs the reverse +walk and outward member seeding. Missing direction retains documented legacy +orientation. Partial, foreign or contradictory direction grants no guessed link, +including through source-owner promotion. No new dependency edge is invented. + +The original pointer-factory and direct-provider production probes each missed an +accepted context access after JSON reload. Promote those failures into ordinary +regressions. Verify insertion-order reversal, relation/depth/site provenance, +class/member seeding, corrupt transport, default/directed reload and actual CLI +queries. Preserve legacy generic results and graph bytes. Source/update parity +and existing publication failure/retry remain applicable because this is a read-only +consumer correction. Record measured legacy growth and reviewed artifact proof; +no AST or Qt policy change is required. Review the plan at exit. + +## INC-QML-31 — JSON direction integrity before publication + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-013-AC01/AC03, REQ-QML-018-AC04/AC06 and REQ-QML-011-AC04. +Owner: serialization/persistence boundary maintainer. Dependency: INC-QML-30's +shared logical pair validator and existing publication completion contract. + +JSON serialization must not convert corrupted direction markers into new graph +endpoints. At the reproduced baseline, a source-backed Qt edge with a foreign +`_src` publishes that foreign source; reload then presents a valid-looking pair. +Partial markers are silently removed. Reject invalid pairs before writing under +the existing QT_EXPORT_DIRECTION contract, preserving previous output and reporting +failure. Force cannot authorize corrupt direction. Valid full pairs and unmarked +legacy graphs retain their supported serialization behavior. + +Promote a real production graph/export/reload/affected reproduction. Cover partial, +foreign, wrong-type, contradictory directed and valid undirected pairs, parallel +mechanisms, exact retained graph/edge metadata, existing-file bytes and repaired +repeat output. Exercise actual manual/watch publication failure/recovery at this +serialization seam and unrelated export regressions. Use the shared validator +without duplicating direction ownership or changing source facts/runtime ordering. +Record any affected database preflight implication, measured legacy size and +installed artifact proof. No AST/Qt epoch is needed; review the plan at exit. + +## Adoption integration scope + +INC-QML-08b and INC-QML-29 supply one accepted declared API path. INC-QML-30–38 +correct its persisted consumers, source-role compatibility, comparison guards, +cleanup authority, mechanism proof, per-occurrence transport and first-publication +origin. Their source changes and ordinary regressions form one reviewed adoption +integration change set so those contracts remain coherent at the saved revision. +Each correction retains its distinct scope, acceptance IDs and exit review. +INC-QML-08c closes bounded installed acceptance for that exact source payload. +Producer schema 12 and derived Qt policy 18 are the final epochs; consumer and +publication-only corrections do not add separate schema epochs. + +## INC-QML-32 — First-update repeat stability + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-011-AC01/AC04 and REQ-QML-018-AC05/AC06. +Owner: update comparison maintainer. Dependency: actual cold extraction and the +existing raw no-cluster publication contract; no producer or policy change. + +An unchanged second update preserves accepted source graph bytes and modification +time. Cold extraction's transient extracted-source list and absent-versus-empty +diagnostic/failure/hyperedge lists cannot alone trigger a graph rewrite. Nonempty +diagnostics, wrong-type transport, unknown graph metadata and changed source facts +still affect the comparison. The stored first output remains intact; normalization +belongs only to the read-only comparison, without changing writers or discarding +meaningful metadata. Clustered topology comparison retains its existing contract. + +Promote real manual/watch first-cold and repeat cases for both CMake/qmake, +whole-root/safe-subroot and generic Python controls. Verify bytes and mtime, +actual source edits, replacement failure retention, repaired retry and repeated +output. Update installed external CLI proof, full compatibility evidence and +measured legacy ceiling; review the plan at exit. + +## INC-QML-33 — C++ file provenance compatibility + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-008-AC02/AC03, REQ-QML-020-AC01–AC03, +REQ-QML-011-AC01/AC04 and REQ-QML-018-AC05/AC06. +Owner: canonical source-file projection maintainer. Dependency: INC-QML-15's +accepted source-size/include/shadow provenance; no new producer schema. + +Canonical C++ file nodes remain valid project-membership/source-containment +endpoints when their exact bounded constructor-type provenance is present. +That file role grants neither constructor nor Qt SDK type authority. Callable, +class, foreign, duplicate, non-AST, malformed or unrelated semantic metadata +cannot borrow file identity. One shared accepted-file role owns both projections. + +The original positive membership cases fail because the old guard requires all +metadata to be absent. Source-containment fixtures also assumed valid constructor +overloads lose ownership; exact overload admission supersedes that assumption. +Equivalent duplicate bodies preserve real ambiguity for those unchanged +containment/rejection assertions. Retain their source/edge corruption controls, +add exact producer-transport rejection cases, and verify production reload, +manual/watch membership edits/removal, failures and corrected retry. Rebuild the +reviewed installed wheel and rerun affected contribution gates before closure. +Record responsibility boundaries and review the plan at exit. + +## INC-QML-34 — Integrity before unchanged-topology acceptance + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-013-AC03, REQ-QML-011-AC04 and REQ-QML-018-AC06. +Owner: update publication maintainer. Dependency: the accepted-pair validator and +INC-QML-31's rejection contract; no source producer or epoch change. + +Candidate direction must pass integrity checks before an unchanged-topology +shortcut accepts its checkpoint. Removing one marker from a real containment edge +can leave the old topology comparison equal and skip the serializer entirely; +the reproduced run returns success without QT_EXPORT_DIRECTION. Retained old +graph bytes do not make that accepted candidate valid. Raw no-cluster publication +must uphold the same pair contract independently of JSON export. + +Validate complete candidate pairs using the existing shared validator before +comparison/publication, with native directed identity and safe bounded rejection. +Force cannot bypass integrity. Keep valid legacy/default/directed graphs, +topology semantics, accepted state and writer ownership unchanged. Promote actual +build/extraction-seam manual/watch raw/clustered regressions, meaningful corruption +and valid controls, prior cohort/cache retention, corrected retry and repeated +completion. Reverify serializer/query/first-repeat compatibility and the installed +artifact; record measured legacy growth and review the plan at exit. + +## INC-QML-35 — Deleted C++ include stub retention + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-011-AC01/AC04 and REQ-QML-018-AC05/AC06. +Owner: incremental node-retention maintainer. Dependency: accepted source ownership +and existing orphan-reference cleanup; no producer schema change. + +Deleting the final C++ source that imports an unavailable quoted header removes +its unreferenced generic include stub. A clean extraction omits that node; manual +and watch updates must produce the same result. Source-backed definitions, +still-referenced external nodes and unrelated semantic data retain their existing +ownership and preservation contracts. Text labels cannot authorize broad removal. + +The production fixture leaves an isolated `missing` node after deleting +`backend.cpp`, while membership correctly becomes unavailable and loses its file +endpoint. Promote that regression into ordinary manual/watch deletion, restoration +and repeated-update parity checks. Characterize shared references and unrelated +source controls before correction, retain publication failure/recovery protection, +and reverify the reviewed installed wheel. Record cleanup responsibility and +measured legacy size, then review the plan for further reproduced gaps. + +## INC-QML-36 — Source-backed bridge mechanism integrity + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-018-AC04/AC06, with affected original bridge/provenance +criteria reverified. Owner: Qt/QML graph projection maintainer. Dependency: +accepted occurrence, endpoint and callback proofs; no producer schema change. + +Changing a property-read edge to `calls` cannot borrow otherwise-valid context +proof. Source kind, scope, span and endpoint role authorize each projected +relationship and context. Preserve accepted calls, property reads, imports, +loads, access, handlers and signal subscriptions as separate mechanisms. A +resolved endpoint alone cannot authorize a different source occurrence or edge +mechanism, and no runtime delivery or ordering is inferred. + +Promote actual raw/build/JSON-reload counterexamples into ordinary regressions. +Cover relation/context substitution and original owner, kind, span, status and +lexical authority, plus valid property/call/NOTIFY/subscription and unrelated +language controls. Exercise affected persisted consumers and retain graph/fact +payloads. Validate the focused owner and reviewed installed wheel, update +acceptance/traceability and review the plan at exit. + +## INC-QML-37 — Per-occurrence cross-language event transport + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-016-AC02/AC04, REQ-QML-017-AC02/AC04, +REQ-QML-011-AC03/AC04 and REQ-QML-018-AC06. +Owner: cross-language event resolver maintainer. Dependency: source-owned native +event roles and established literal QML handles. + +Each accepted connect/disconnect occurrence annotates its own endpoint nodes and +edges once. Repeated source sites cannot re-encode earlier literal transport, +rewrite their operation or replace their QML/C++ direction. Existing partial +native role facts retain canonical identity and receive the final accepted bridge +annotation. Their same-occurrence edge context is upgraded before raw pair +deduplication; distinct occurrences and relationship mechanisms remain separate. + +The production reproduction fails metadata decoding for two sites and extraction +for three or more. Ordinary raw/facade/build/JSON/reload tests cover 1–16 sites, +both directions and mixed connect/disconnect operations, exact spans and bounded +literal transport. Manual/watch C++/QML edits, stale endpoint retirement, +restoration, unrelated Python preservation, policy-only refresh and actual Windows +publication rejection/repaired repeat pass. Qt policy 18 retires derived event +facts with AST schema 12 unchanged. Review final installed evidence and the plan +at exit; runtime delivery and ordering remain unproven. + +## INC-QML-38 — External stub provenance at first raw publication + +Status: **Implemented; locally complete for the bounded source/update and reviewed installed profile**. +Acceptance: REQ-QML-011-AC01/AC04 and REQ-QML-018-AC05/AC06. +Owner: raw publication maintainer. Dependency: the canonical external-stub mint +boundary and accepted node-origin compatibility; no producer schema change. + +An external placeholder receives its established publication origin when first +minted. The second unchanged update cannot rewrite accepted bytes merely to +backfill that origin. Only newly appended builder-created nodes are stamped; +existing AST, source-backed, manual and semantic nodes retain their provenance. +Do not strip node origins from comparison or broaden deletion authority. + +The C++ missing-include fixture first published no origin and gained semantic +origin on repeat. Promote exact cold/manual/watch first-repeat bytes/mtime, +generic import controls, genuine edits/deletion/restoration and actual publication +failure/repaired retry. Reverify the INC-QML-35 ownership matrix and installed +external CLI. Record measured legacy size and review the plan at exit. + +## Final adoption exit review + +INC-QML-11/15/08 and INC-QML-28–38 have implemented contracts, ordinary regression +coverage, reviewed source and installed proof for the documented Windows profile. +The reviewed index tree is `9f0ae4ea75f47539df8ec8a66f1493b9e7f52b2f`; all 179 Python +modules match the wheel and installation exactly. Final documentation closure +changes only documentation. The full source comparison adds no failure identity; +the retained baseline failures and unexecuted hosted lanes prevent an all-green +release claim. Source/installed commands and artifact digest are recorded in +[validation](VALIDATION.md#final-adoption-delivery). + +At the recorded local adoption checkpoint, no further reproduced implementation +defect remained open. The subsequent hosted run reproduces the path-alias gap +allocated to INC-QML-39 below. Publication must recheck upstream/PR overlap, +run the affected hosted matrix and retain or resolve documented contribution-gate +exceptions. Native interaction, live service proof and excluded dynamic Qt forms +remain explicit evidence or scope limitations, not silently completed increments. + +## INC-QML-39 — Canonical accepted-input identity + +Status: **Verified for the recorded source, installed and hosted input-alias profiles**. + +Acceptance: REQ-QML-020-AC01–AC03, with existing corpus/failure guards +REQ-QML-004-AC04 and REQ-QML-012-AC01/AC02. Dependency: the implemented project +membership projection and reviewed installed-wheel profile. + +Accepted input paths can name the same file through a Windows short path or a +parent-directory alias. Membership admission must compare canonical contained +input identity against its source declaration while retaining the existing +lexical provenance and literal lookup contracts. No file-content reader, corpus +discovery, facade/cache/persistence owner or second runtime changes. Existing +conflict/outside-root rejection and publication diagnostics remain authoritative. + +The first hosted installed-module smoke fails on Windows and macOS. A real +Windows short-path reproduction fails +the unchanged installed smoke assertion; canonical spelling succeeds. Correct +the owning comparison, then retain real alias/canonical success, foreign/conflict +rejection, cold/warm/full/update equality, stale membership removal and prior- +product retention evidence. A real parent-directory symlink supplies the portable +alias fixture where the platform permits it; unavailable capability remains an +explicit exclusion with hosted platform proof still required. +The same alias mechanism is a hypothesis for macOS until corrected hosted proof +confirms it. Focused source tests pass 48 cases with seven symlink-capability +skips; the broader frozen Qt/QML source selection passes 2,259 with nine skips. +A fresh source-identical wheel passes ordinary QML/core smoke, but a near-root +Windows temporary-directory alias exposes the separate facade provenance gap +assigned to INC-QML-40. Complete installed/hosted alias acceptance remains open. + +Exit: ordinary regressions reject the original defect and pass the correction; +fresh installed smoke passes without relaxed assertions or fixture normalization; +the affected Qt/QML and unrelated-language boundaries pass; all applicable hosted +lanes execute and retain current revision, source/dependency and cleanup evidence. +Preserve Qt policy 18 and AST schema 12 because accepted fact/graph contracts and +refresh ownership are unchanged. The installed proof and failure review reproduce +the separate INC-QML-40/41 gaps below; no speculative increment is allocated. + +## INC-QML-40 — Canonical facade source provenance + +Status: **Verified for the recorded source, installed and hosted facade-alias profiles**. + +Acceptance: REQ-QML-020-AC01/AC03 and REQ-QML-004-AC04. Dependency: INC-QML-39. +Owner: existing extractor-facade source-file normalization boundary. + +The facade compares an absolute source path lexically against its canonical +root before applying the external-file policy. A real near-root short alias +therefore publishes traversal provenance for contained C++ sources; the native +type validator correctly rejects it. Canonicalize input identity before the +containment decision, keeping true external-file policy, endpoint remapping, +source spans, lexical facts and native provenance rejection intact. Reuse the +existing per-source resolution cache; add no corpus reader or alternate owner. + +Cover a real near-root Windows short path and portable parent symlink, canonical +versus alias generic/Python/C++/QML IDs, edges and source ranges, unchanged native +smoke, actual cold/warm and update parity, and true external controls. Do not +relax traversal guards or depend on the external helper's basename fallback. +The facade is oversized legacy code (8,993 physical lines before this correction, +8,997 after), within its documented 9,010-line ceiling. Preserve facade ownership +and the separate characterized migration playbook. +Compatibility decision D23 uses policy 19 to refresh older policy-18 native/Qt +products at unchanged package/source versions; AST schema 12 remains because +cached facts precede normalization. Cover actual no-change upgrade, failed-refresh +retention, repaired retry and repeat. Generic-only prior API outputs require an +explicit forced rebuild for repair. + +Exit: focused shared-boundary and alias regressions, fresh installed consumption +and applicable hosted lanes pass without fixture normalization. Record artifact +identity and review the plan again. Policy 19/schema 12 preserve existing +cache/product owners under the documented compatibility decision. + +Local evidence: nearby-alias/definition-file, policy-upgrade, existing membership +and six unrelated source-remapping/portability modules pass 65 cases with fourteen +symlink-capability skips. Fresh installed QML/core and real near-root short-alias +smoke pass without changing their assertions. The original facade selection fails +four cases before correction; four mixed/C++-only writer upgrade cases fail under +policy 18 and pass under 19. INC-QML-41 below closes the separately reproduced +diagnostic gap; no further reproduced gap is allocated at this checkpoint. + +## INC-QML-41 — Failure-safe join diagnostics + +Status: **Verified for the recorded source, installed and hosted join-failure profiles**. + +Acceptance: REQ-QML-012-AC01/AC02 and REQ-QML-020-AC02. Dependency: the existing +Qt/QML join/publication guards. Owner: `qt_qml_pipeline` diagnostic construction. + +If an accepted input's filesystem identity fails persistently, membership rejects +it, but diagnostic construction resolves it again and can rethrow the raw backend +exception. Direct joins stop with partial diagnostics; manual/watch reject and +retain durable products but print that exception body. Diagnostic construction +must remain bounded, annotate all affected sources with `QML_RESOLUTION_FAILED`, +retain safe relative context where available, and use an explicit unavailable +context when necessary. No new diagnostic code, lookup authority, graph-write +ordering or persistence owner is introduced. + +Cover OSError and alias-loop RuntimeError at the real accepted input after facts +and index exist, through direct joins and manual/watch writers. Assert bounded +diagnostics, no raw body, complete failure annotation, unchanged prior graph, +manifest, analysis stamp and root marker, corrected retry and repeat. Include +normal contained context and alias/outside-root fallback without private paths. + +Exit: ordinary regressions fail before and pass after the correction; source, +installed and current hosted boundaries retain accurate failure/cleanup evidence. +Review the plan and allocate further work only for another reproduced gap. + +Local evidence: nineteen failing diagnostic cases before the correction become +22 passes with three symlink-capability exclusions. Existing failure/membership/ +update compatibility passes 136 cases with ten symlink skips. The changed helper's +seven statements and two measured branches are covered; legacy uncovered paths +remain unchanged. Complete diagnostics, four prior products, repair and byte-stable +repeat pass through direct/manual/watch boundaries. Fresh source-identical wheel +smoke and real Windows near-root alias pass. The installed alias, policy-upgrade +and diagnostic selection passes 33 cases with ten symlink-capability exclusions; +all loaded production modules originate in that artifact. The plan review finds no further +reproduced implementation gap at this checkpoint; INC-QML-42 is unallocated. +Current hosted execution and the pre-existing whole-project typing failure remain +open evidence/gate limitations, not passing acceptance. + +## INC-QML-42 — Distinct discovered symlink provenance + +Status: **Verified for the recorded source, installed and hosted discovered-source profiles**. +Owner: extractor-facade source normalization. Acceptance: REQ-QML-020-AC01/AC02/AC03 +and the existing unrelated-language provenance contract. Dependency: INC-QML-40. + +Physical containment distinguishes accepted inputs from external sources. It +must not erase distinct walked source identities inside that accepted corpus. +The first corrected hosted head collapses a target and its leaf symlink into +one source label, and changes a followed-directory watch rename from its lexical +source path to the physical target. Keep both existing upstream regressions and +restore their observed contract while retaining contained parent/short aliases. + +Preserve source_file/definition_file, stable IDs, topology, cold/warm cache groups, +full/manual/watch rename/deletion/repeat and real failure retention. External +targets must retain their existing policy and grant no native authority. Bound +per-run root-alias work, document ownership and any required derived-policy +refresh, and preserve the existing facade ceiling. Generic-only old outputs need +an explicit repair procedure if the existing refresh owner does not admit them. + +Exit: original Linux regressions and ordinary new shared-boundary coverage pass; +fresh installed aliases, native rejection, cache/update and current hosted profiles +retain individual passing evidence. Review the plan after the correction. + +Local correction evidence: nine ordinary cases pass, one actual file-symlink +capability case is skipped. Real Windows junctions exercise double aliases, +disjoint file/symbol/call identities and writer transitions; the unchanged shared +compatibility selection passes 405 cases with 31 capability exclusions. The helper +covers all fifteen measured statements and four branches. The fresh 181-module +artifact checkpoint passes 43 installed cases with eleven symlink skips across +overlapping alias/upgrade/diagnostic/fixture selections. Original Linux failures +remain assigned to corrected hosted execution. The plan review adds INC-QML-44 +for a separately reproduced single-alias watch notification and INC-QML-45 for +actual NTFS short-leaf spelling; those contracts are not closed by this checkpoint. + +## INC-QML-43 — Nearby Windows alias fixture portability + +Status: **Verified for the recorded local, installed and hosted native fixture profiles**. +Owner: facade-alias acceptance fixture. Acceptance: REQ-QML-020-AC01/AC02/AC03. +Dependency: INC-QML-40. No Graphify runtime or analysis epoch change. + +GetShortPathNameW can shorten multiple Windows temporary-directory ancestors. +Its full returned spelling then violates the fixture's deliberate nearby-path +precondition before any product assertion runs. Use the real returned short +basename with its canonical existing parent. Preserve distinct real filesystem +identity, canonical-target equality and the three-ancestor maximum; do not weaken +or skip these checks. Full short-path input remains separately tested. + +Exit: a real multi-ancestor short-path setup fails before and passes after the +fixture correction; all existing production/source/definition/native/update +assertions remain intact and the Windows hosted profile executes them. Review +the plan again and allocate further work only for another reproduced gap. + +The real five-long-ancestor fixture fails its original precondition with ten +ancestor hops before correction; the corrected real short basename uses one hop +and passes production cold/warm source/edge checks. Existing/new facade selection +passes eight cases with seven actual file-symlink capability skips. The same new +case passes in the reviewed installed artifact. All original production assertions +are retained; full-path short-input and unchanged installed smoke remain separate +coverage. INC-QML-44/45 capture the adjacent gaps discovered in the exit review. + +## INC-QML-44 — Shared physical-source watch invalidation + +Status: **Verified for the recorded source, installed and hosted co-owner profiles**. +Owner: watch accepted-input invalidation. Acceptance: REQ-QML-011-AC01/AC03/AC04, +REQ-QML-020-AC03 and relevant REQ-QML-012 diagnostic/retention criteria. + +Two accepted walked paths can share physical content. The earlier selector +refreshes only the notified owner and retains stale functions under the other. +Expand invalidation only over already +accepted nonsemantic regular-file owners with verified contained physical +identity, including supported real hardlinks. Preserve distinct walked names, +ordinary rename/deletion, unrelated inputs and full/incremental topology. + +Keep per-call identity grouping separate from discovery, durable state and cohort +publication. Unexpected identity failure must reject boundedly, retain prior +products/cache and omit backend bodies; repair and no-change repeat must succeed. +Document derived refresh and generic-only full-rebuild recovery. Exit with real +alias/hardlink notifications, metadata/native controls, stale removal, actual +failure/retention and source/installed/hosted evidence. Review the plan again. + +## INC-QML-45 — Canonical NTFS short-leaf input spelling + +Status: **Verified for the recorded source, installed and hosted native spelling profiles**. +Owner: extractor input spelling. Acceptance: REQ-QML-020-AC01/AC02/AC03 and +relevant REQ-QML-011/012 update/diagnostic criteria. + +An actual NTFS 8.3 short header spelling identifies the same directory entry as +its long name. The earlier input conversion changes native member identities +and silently loses its accepted CMake membership. This is distinct from separately +discovered symbolic/junction aliases, whose walked identities must remain separate. + +Normalize supported alternate file spelling without resolving discovered reparse +aliases into their targets or expanding the corpus. Preserve parser dispatch, +original bytes/spans, literal metadata, cache ownership, source/definition roles, +native external/traversal rejection and cold/warm/full/incremental parity. SDK +execution and new runtime dependencies are excluded. Cover real short files and +failure/absence handling, version refresh and retained-product recovery before +source/installed/hosted acceptance. Review the plan after the correction. + +Three ordinary INC-QML-45 modules pass 38 cases without skips in the native +Windows source profile. Actual short header/build metadata, both discovered +junction owners, foreign-root rejection and supplementary Unicode retain exact +facts/IDs/spans and cold/warm/reload. Native API zero/oversized/empty/wrong-length/ +changed-target/backend failures reject before work and retain actual manual/watch +products and cache bytes; repair/full parity/repeat pass. Canonical-first/last +absolute/relative inputs preserve primary source ownership; ambiguous import +forms retain ordinary non-authoritative builder stubs without choosing an alias. +Source helper/facade measure 125/9,005 within their 300/9,010 limits. Combined +policy 21 retains AST schema 12. + +### Combined exit review — INC-QML-44/45 + +The final fifteen-module source profile passes **640 cases, zero failures, +21 capability/profile skips**; all 59 new cases pass without skips. The reviewed +182-module wheel passes unchanged QML/core/full-TEMP short-alias smoke and +**102 installed cases, eleven file-symlink skips, zero failures/errors/warnings**. +All loaded Graphify code originates in that source-identical artifact. Failure +checks preserve actual cache/cohort bytes, and owned temporary fixtures are removed. +Helper statement/branch coverage is complete; Ruff and 134 generated-artifact +checks pass. Full typing retains 604 exact baseline errors, with zero added or +removed, and remains a failed gate. + +The source/installed exit review finds no further reproduced gap in this scope; +INC-QML-46 was unallocated at this local checkpoint. The corrected normal PR run owns real POSIX, +hosted Windows and optional-wheel matrix proof. New runner failures create a +focused regression/correction obligation; local completion does not pass an +unexecuted hosted check, typing gate or native device/system procedure. + +## INC-QML-46 — Explicit followed-directory upgrade fixture profile + +Status: **Verified for the recorded source, installed and hosted discovery profiles**. +Owner: manual discovery option and Qt policy-upgrade acceptance fixture. +Acceptance: REQ-QML-020-AC03 and REQ-QML-011-AC01/AC03/AC04. +Dependency: INC-QML-42/44/45. + +Hosted Linux source `38bf8cc` fails both manual/watch policy-upgrade cases because +their directory alias is a POSIX symlink, but their shared runner and cold/full +comparison use default discovery, which excludes followed directory symlinks. +The native Windows junction fixture is traversed under that default and concealed +the mismatched profile. The manual update parser additionally rejects the +discovery option supported by the watch rebuild API. Add the explicit manual +option and align the fixture; default discovery continues to exclude links. + +Acceptance matrix: the real CLI, watch update, initial older-policy publication, +failed staging, repaired retry, full/cold comparison and repeat must use the same +explicit followed-directory profile through `graphify update --follow-symlinks` +and the existing watch API. Preserve exact alias source and +topology assertions, prior stamp/cache/product retention and recovery. Default +discovery still excludes a POSIX directory symlink; no fixture can broaden the +analyzed corpus by changing a production default. Windows junction and real +POSIX profiles retain their distinct capability evidence. + +Keep shared helper options default-compatible. The CLI owns option admission +and forwards discovery to the existing rebuild owner; no source parser, Qt +policy/schema, dependency or durable-product format changes. Unknown flags and +multiple roots still reject before publication. Diagnostics are unchanged: forced +publication failure retains its existing owner and recovery assertions. Exit +with focused unchanged assertions, default-exclusion controls and corrected +hosted Linux execution. Review the plan after the run and allocate another +increment only for a separately reproduced gap. + +## INC-QML-47 — Native entry-spelling fixture expectation + +Status: **Verified for the recorded source, installed and hosted native fixture profiles**. +Owner: membership alias warm-cache fixture. Acceptance: REQ-QML-020-AC01/AC02/AC03. +Dependency: INC-QML-45. + +Hosted Windows optional-wheel source checks fail the warm-parser input spy for +the real short-parent fixture. INC-QML-45 expands alternate NTFS entry spellings +before parser dispatch. The spy still expects the unexpanded short parent even +though smoke, cold/warm facts and membership identity pass. Require the exact +long spelling for this native fixture and keep exact lexical spelling for a +distinct symlink owner. Do not replace the assertion with physical equivalence +that could hide a collapsed discovered owner. + +Retain all cached-Python exclusion, fresh native/QML/metadata input, source/ID, +graph/membership parity, foreign/conflicting input, publication retention and +recovery assertions. Production and diagnostic contracts do not change. Exit +with the old expectation failing the unchanged accepted normalization, the +corrected exact-path contract passing, and corrected hosted native evidence. +Review all source lanes for another independently reproduced gap. + +### Combined exit review — INC-QML-46/47 and INC-CORE-08 + +The source profile passes 70 cohort/discovery cases with two native capability/ +profile skips; broader CLI/watch compatibility passes 253 with five such skips. +All seven new CLI cases and both corrected upgrade cases pass. Native shell +consumer selection passes 161 with eight established platform exclusions; all +eighteen helper cases pass. Membership source checks pass nine with seven actual +file-symlink capability skips, one existing Hypothesis warning. These profiles +overlap and do not form a full-suite total. + +The fresh 182-payload source-identical wheel passes unchanged QML/core/full-TEMP +short-alias smoke and 118 installed cases with nineteen explicit capability/ +profile skips. The new CLI contracts, older-policy refresh, native short-parent +warm inputs and all 59 prior co-owner/spelling cases pass individually. Loaded +Graphify modules come from the artifact; dependencies, source and original +coverage stay unchanged. Source/installed writer failures retain cache/products +and repaired retry. Owned temporary fixtures are removed. + +Ruff, workflow syntax and actual Python shell/consumer admission pass. Current +Pyright retains 604 exact baseline errors with zero added or removed; this is a +failed gate. AST-only graph refresh completes. No further reproduced gap appears +in the local exit review; INC-QML-48 remains unallocated. The next normal PR event +owns corrected four-lane Linux, native Windows and twelve-lane optional-wheel +proof. Existing physical device/system and advisory security limits remain +explicit; no merge is performed by this increment. + +The subsequent hosted native profile passes 836 cases with 21 explicit skips, +but omits its generated shell-consumer report from the uploaded artifact. The +shared [INC-CORE-09](../COMPATIBILITY.md#native-consumer-evidence-retention--inc-core-09) +corrects that evidence publication gap. It changes neither Qt/QML source behavior +nor Qt increment numbering. The subsequent Python 3.10 fixture correction owns +INC-QML-48 below. + +At source `f699b0f`, all twelve optional Qt/QML lanes and all assigned Linux +co-owner/remap/discovery/CLI cases pass. Three full Linux suites pass, while +Python 3.10 exposes a separately reproduced cleanup-fault fixture seam. The +complete [hosted checkpoint](../../tests/TRACEABILITY.md#hosted-source-and-optional-wheel-checkpoint--f699b0f) +retains source/base/actual checkout, individual outcomes, artifact/source +identity, service cleanup, exclusions and remaining delivery limits. + +## INC-QML-48 — Portable partial-setup cleanup fault injection + +Status: **Verified for the recorded four-version native and hosted cleanup profiles**. +Acceptance: REQ-QML-012-AC02 and REQ-QML-018-AC06. Owner: product-publication test +maintainer. Dependency: INC-QML-25; hosted discovery follows INC-QML-46/47. + +The actual Python 3.10 source job passes 9,007 cases but fails the partial-setup +cleanup scenario. Its fixture patches `os.unlink`; Python 3.10 `Path.unlink` +invokes a cached accessor that bypasses that patch. Cleanup therefore succeeds +and the original snapshot-copy rejection correctly reports +`GRAPH_PUBLICATION_FAILED`. Python 3.12 reaches the patched function and exercises +the intended second failure. This is a test injection gap, not evidence that +production failed to retain an accepted cohort. + +Patch the actual `Path.unlink` method used by production, delegating the saved +method for every unrelated target. Record the injected owned target so the test +cannot pass merely because any setup error produces a cleanup code. Preserve +the exact diagnostic, redaction, original-product and durable recovery-copy +assertions. Production publication, transaction ownership, ordering, errors, +policy/schema, packaging and dependency contracts remain unchanged. Existing +architecture/error-catalog ownership therefore needs no new decision or code. + +| Acceptance scenario | Executed boundary and required outcome | +| --- | --- | +| Existing fixture on Python 3.10 | Reproduce the missing second fault and mismatched code before correction | +| Partial setup then cleanup rejection | Real snapshot preparation fails; exactly the owned old graph unlink rejects; `GRAPH_PUBLICATION_CLEANUP` preserves all accepted bytes and its recovery copy | +| Unrelated cleanup and diagnostic safety | Delegate the real method for other targets; no private path/backend body in the public message; existing preparation/rollback/cleanup controls pass | +| Supported interpreter/platform regression | Ordinary collected publication tests pass on available native Python profiles; next normal four-lane Linux PR evidence includes this exact case without a skip | + +Test corrections remain below the 300-line source ceiling. Verify the prior red +case, corrected native Python 3.10 and 3.12 cases, adjacent production publication +tests and unchanged source/dependency/coverage identities. Refresh the graph after +the test edit. Publish together with the prepared INC-CORE-09 correction so one +normal PR event owns their reviewed revision; prior runs are already terminal. +Exit only with exact corrected hosted outcomes and review all jobs for another +reproduced gap. INC-QML-49 remains unallocated. + +Local exit review: native Python 3.10.21/3.12.14/3.13.15/3.14.7 each pass 82 +publication cases with one actual file-symlink capability skip and zero failures. +The corrected ordinary cleanup case passes individually in every profile; the +byte-exact earlier fixture still fails its 3.10 replay. All original four +assertions remain, with two owning-fault assertions added. Targeted Ruff/Pyright +report zero diagnostics; whole-project typing's 604 baseline errors remain. +Production, dependencies and original coverage bytes are unchanged. Graph refresh, +requirements/links/references and 297-line test ceiling checks pass. No further +reproduced local gap appears; corrected hosted proof remains the exit condition. + +Hosted exit review at source `10cb15a`: all four full Linux jobs, focused native +Windows and all twelve optional-wheel jobs pass on actual checkout `6974373`. +Every assigned co-owner/remap/discovery case plus the exact corrected cleanup +case and five live-service/wheel cases passes individually on all four Linux +versions. Native shell admission and uploaded-report retention pass, with actual +API archive integrity and unchanged source/dependency proof. Installed QML/core +smokes pass in every optional-wheel lane; that workflow's platform suites remain +source profiles without per-case JUnit or retained wheel-digest artifacts. +The [verified checkpoint](../../tests/TRACEABILITY.md#verified-hosted-runner-checkpoint--10cb15a) +owns exact identities, outcomes and limits. The configuration/runtime/test content +is unchanged by this documentation completion. Earlier pending-hosted checkpoint +descriptions are historical; their assigned corrected profiles now pass. No new +reproduced defect is found, so INC-QML-49 remains unallocated. Baseline typing, +advisory security and physical application/device procedures remain explicit +gaps; no merge or deployment is performed. + +## INC-QML-49 — Current-upstream integration + +Status: **Completed for the recorded source/native/installed hosted profiles at 8b6c9d2**. +Owner: upstream integration maintainer. Verified source `65103f8` remains on +`codex/qml-08-adoption-planning`; integration uses the same workspace on +`codex/qml-upstream-integration`. Dependency: INC-QML-00–48 and INC-CORE-01–09. +Target: upstream `v8` at `35adf432b9d50f6f3d530ab5d7ec316819ef081c`, package +0.9.76. Preserve the 62 incoming commits and all accepted Qt/QML contracts; +resolve four content conflicts without rewriting published history. + +Scope decision: this increment integrates the current upstream implementation, +not a parser rewrite or an upstream publication. Existing QML PR #1748 is an +explicit overlap to reconcile before a later focused upstream contribution. +The previous four-Linux/native/twelve-wheel proof tested a different base; it +remains historical evidence rather than acceptance of this integration. + +| Acceptance IDs | Scenario and required observable result | +| --- | --- | +| REQ-QML-002-AC02/AC03/AC04; REQ-QML-020-AC01 | Detection and collection admit exact Qt metadata while excluding installed Graphify copies in default/contained-follow profiles; ignore, foreign-root and named-file rejection remain effective | +| REQ-QML-003-AC04; REQ-QML-011-AC03/AC04 | AST schema 13 invalidates old schema 5/12 entries, retaining both Qt producer facts and upstream receiver-shadow facts; semantic-cache ownership and original source identities remain unchanged | +| REQ-QML-010-AC02/AC03; REQ-QML-018-AC04/AC06 | Cluster-only/label serialization retains all accepted original parallel edges with logical Qt direction; malformed markers preserve prior output; qualified path/node-ID lookup uses the accepted endpoint and directed traversal | +| REQ-QML-018-AC02/AC05/AC06 | Upstream C++ unions, Python import/receiver changes, shrink accounting and package 0.9.76 coexist with Qt parser, bridge, installed-wheel and publication contracts | +| REQ-CORE-004-AC01/AC02/AC03/AC04 | Normal fork PR validation binds complete Linux/native/optional-wheel outcomes, required cases, artifacts, integrity and cleanup to the integrated source/base/checkout without a duplicate dispatch | + +Diagnostic impact: stale cache entries miss under the existing cache owner; +malformed direction still reports QT_EXPORT_DIRECTION before publication. +Corpus exclusion and unresolved path lookup retain existing rejection behaviour. +No new diagnostic or persistence owner is introduced. Failed export/publication +retains accepted graph products under the existing contracts; source analysis +executes no corpus code. D26 records the combined cache compatibility decision. + +Exit: resolve every conflict with source review, add joint-boundary regressions +and retain pre-correction failure evidence, run focused shared/Qt checks plus +proportionate lint/type/generated/package checks, refresh the AST graph, then +publish one integration revision for normal PR proof. Record exact test/skip +identity and remaining baseline/manual gaps. Review the plan after verification; +allocate another increment only for a reproduced separate defect. + +## INC-QML-50 — Followed Qt metadata source provenance + +Status: **Corrected; recorded native and POSIX hosted profiles verified at 8b6c9d2**. +Owner: Qt metadata producer maintainer. Dependency: INC-QML-49's integrated +discovery/cache contracts. The prior 65103f8 producer and the pre-correction integration +both admit a contained followed directory alias but emit only canonical metadata +source paths. The direct qmldir reader already canonicalizes the path before the +facade receives its facts. This contradicts REQ-QML-020-AC02's separately +discovered lexical-source contract; collector admission alone does not prove it. + +Scope decision: preserve canonical physical admission and cache identity while +retaining each accepted lexical fact owner, original-byte spans and nested +references through direct readers, facade, persisted graph and actual manual/ +watch updates. Correct the owning producer/transport boundary without broadening +containment or rewriting unrelated analyzer mechanisms. Root owns integration +and documentation; the metadata maintainer owns disjoint producer/regression files. + +| Acceptance IDs | Scenario and required observable result | +| --- | --- | +| REQ-QML-020-AC02; REQ-QML-003-AC04 | A real contained followed directory supplies separate canonical and lexical qmldir/qmake/QML source owners with exact locations and stable cold/warm facts | +| REQ-QML-020-AC01/AC02/AC03; REQ-QML-012-AC01/AC04 | Default exclusion, foreign aliases, ignore rules, cycles and nonregular inputs retain their established admission/rejection behaviour; native spelling rejection keeps its safe stage-specific code | +| REQ-QML-011-AC03/AC04; REQ-QML-018-AC05/AC06 | Real metadata edits and manual/watch rebuilds match a clean build, remove stale facts, preserve accepted products on failure, and recover/repeat safely | + +Diagnostic impact: accepted aliases must not disappear as a successful empty +producer result. Existing source/context and publication errors retain their +owners and safe wording. Unsupported/foreign paths remain rejected without +executing corpus code. Cache/schema impact follows inspection of the fact +contract before correction. Native junction and POSIX link evidence are distinct +profiles; unavailable capabilities remain explicit rather than passes. + +Exit: retain the full frozen-producer counterexample, promote real alias assertions +into ordinary regressions, correct the producer, verify direct/full/warm/manual/ +watch/export and relevant failure controls, document any necessary epoch change, +refresh the AST graph and include the correction in one normal integration PR run. +Review the plan for further reproduced gaps. + +Local exit review: the combined integration selection passes 83 cases; sixteen +POSIX link/`..` controls are explicitly inapplicable to the native Windows +profile and assigned to hosted POSIX lanes. All 43 real alias cases and all six +native-code/context/failure/recovery cases execute without skips. Existing +membership/alias/short-name compatibility passes 133 cases with 23 explicit +platform/capability exclusions before the final missing-context correction; +the six corrected native cases pass again afterward. The independent final +profile and distributable proof are recorded in traceability. No additional +reproduced implementation gap remains at this local checkpoint. The first +integrated runner subsequently exposes INC-QML-51/52 below; its failed result +is retained and does not establish the integrated hosted exit gate. + +## INC-QML-51 — Platform selection guard integration + +Status: **Corrected; local and recorded hosted source profiles verified at 8b6c9d2**. +Owner: optional-wheel validation maintainer. Dependency: INC-QML-49. +At source dbc4859 the native runner correctly includes `test_upstream_qt_` +alongside QML/Qt source tests, but its existing guard requires the previous +literal two-prefix expression. macOS and Windows lanes report this exact +assertion failure; isolated installed QML/core smokes succeed. + +| Acceptance IDs | Scenario and required observable result | +| --- | --- | +| REQ-QML-001-AC01/AC03; REQ-QML-014-AC01/AC03 | Execute the actual trusted runner selection against temporary files: native selection includes QML/Qt/joint tests, sorts them and excludes unrelated files; artifact-only mode selects its one artifact module | +| REQ-QML-014-AC04; REQ-CORE-004-AC03 | Preserve optional/core, isolated offline and no-skip/xfail assertions; retain the original failed guard and exact corrected test and hosted lane results | + +Scope/diagnostics: correct the guard, preserving runner behaviour and the +install/smoke boundaries. No runtime, dependency, cache, schema, persisted graph +or diagnostic contract changes. This test reads trusted repository code and +executes only its selection expression; corpus files remain inert empty inputs. +Local reproduction is one passed/one failed platform test. The corrected +platform tests both pass, with Ruff/Pyright reporting zero diagnostics. The +fixture reverses actual file enumeration only at its owned glob boundary, +requiring production sorting even on a host whose directory is already sorted. +Separate +artifact tests require their reviewed wheel input; an absent input is a skip, +not artifact acceptance. Exit with corrected normal-PR lane evidence and review +the plan for further reproduced failures. + +## INC-QML-52 — Native metadata-growth fixture identity + +Status: **Corrected; local and recorded hosted source profiles verified at 8b6c9d2**. +Owner: metadata regression maintainer. Dependency: INC-QML-50's actual native +source identity admission. Windows source lanes report a second failure in +`test_cmake_scope_limit_and_source_file_growth_are_rejected`: its fake stat +retains only size, so native admission rejects missing identity before the +oversized-byte read it intends to test. POSIX does not use that native boundary. + +| Acceptance IDs | Scenario and required observable result | +| --- | --- | +| REQ-QML-002-AC03; REQ-QML-012-AC01/AC04 | Preserve actual regular-file identity while understating reported size; real oversized bytes still produce metadata_size_limit after native admission, and deep syntax still produces cmake_scope_limit | +| REQ-QML-014-AC02/AC03/AC04 | Retain both original rejection assertions, reproduce the native fixture failure before correction and run corrected native versions plus normal hosted source profiles | + +Scope/diagnostics: fix only the stat fixture seam; retain production identity, +containment, bounded-read and safe diagnostic contracts. No bypass or relaxed +expected diagnostic is permitted. Root owns documentation and Git integration; +the metadata regression maintainer owns the one disjoint test file. Exit with +individual corrected hosted outcomes and unchanged runtime/dependency/coverage +identities. Native Python 3.10.21/3.12.14/3.13.15/3.14.7 each pass all 35 metadata +boundary cases with zero failures or skips after the actual native 3.14 failure +is retained. Original scope/read-size assertions remain unchanged; actual +non-size stat and same-file witnesses prove native admission reaches that boundary. +The changed fixture is 99 lines, below the 300-line ceiling. No runtime or +dependency change is required. Review subsequent failures; INC-QML-53 remains +unallocated. + +### Integrated exit review — INC-QML-49–52 / INC-CORE-10 + +The [verified integrated checkpoint](../../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2) +records passing normal-PR validation of source 8b6c9d2 against exact upstream +35adf43: four full Linux suites, native Windows and twelve optional-wheel lanes. +All assigned applicable joint, alias, native/POSIX, guard, metadata and subprocess +cases pass individually where JUnit evidence is retained. Installed QML/core +smokes pass in every wheel lane; platform source counts retain their separate +log-only evidence limitations. Input/dependency integrity, archive digests and +required disposable-service cleanup are verified. + +The failed dbc4859 checkpoint remains visible. Corrections preserve source +authority, retention, original assertions and all declared lane assignments. +The plan review finds no additional reproduced integration defect; INC-QML-53 +and REQ-QML-022 remain unallocated. Baseline full typing, advisory security, +physical browser/device/application procedures and upstream publication remain +separate gaps. This evidence completion changes documentation only; its later +head receives normal PR validation and does not inherit a fictitious tested SHA. + +## INC-QML-53 — Focused upstream contribution + +Status: **Locally prepared; upstream review and final-head hosted proof pending**. +Owner: upstream contribution maintainer. Dependency: the integrated +INC-QML-49–52 checkpoint. Upstream base remains `35adf43`; preserve the full +tested fork at `82a4f29` and use `codex/qml-upstream-contribution` in the same +canonical workspace. No history rewrite or default-branch merge is included. + +Scope decision: retain the bounded Qt/QML analysis, metadata, native event, +bidirectional bridge, incremental integrity and Qt consumer contracts. Restore +upstream repository policy, native Codex hook serialization and generic HTML +grouping/navigation behavior; those independent concerns need separate PRs. +Retain shared source-identity, atomic-publication and direction fixes because +Qt acceptance depends on them. Preserve requirement/criterion identities and +historical evidence while marking excluded features deferred. + +Scope refinement: the first broad local run exposed one retained membership +test's dependency on the deleted generic inspector harness. Preserve its +external links and exact internal/external source-edge counts through the +upstream inspector seam. These counts remain a Qt membership prerequisite; +partition recovery, selection redesign and navigation remain deferred. Remove +only the assertion owned by deferred startup-selection acceptance. + +| Acceptance IDs | Required result | +| --- | --- | +| REQ-QML-010-AC02/AC03; REQ-QML-018-AC04/AC06 | Retained Qt source fields, tooltips, inspector/search, logical direction and source payloads survive the scoped HTML/export consumers | +| REQ-QML-019-AC01–AC04; REQ-QML-021-AC01–AC03; REQ-CORE-001-AC01/AC05 | Independent features are absent from the contribution and explicitly deferred; the complete fork's proof remains source-bound | +| REQ-QML-001–018; REQ-QML-020 | Parser, native/source, metadata, scope, bridge, update and packaging contracts retain applicable automated assignments | +| REQ-CORE-004-AC01–AC03 | New normal-PR proof binds scoped source/base/actual checkout and applicable cases to its run; excluded test paths do not remain in job selections | + +Reconcile the open #1748 proposal at head `7b38d4c` through a recorded source +comparison and explicit common-foundation integration note. Follow upstream's +PR template with actual commands, supported forms, gaps and truthful AI +attribution. Do not infer accepted integration, release or runtime semantics. + +Diagnostic impact: deferred generic viewer and native-hook diagnostics are not +new contracts in this source. Retained Qt parser, identity, direction and +publication diagnostics keep their existing owners and failure retention. +No parser/dependency/cache epoch changes accompany scope cleanup. HTML and +installer paths revert to upstream behavior apart from retained Qt payloads and +membership-edge counters. + +The [scoped local preparation](../../tests/TRACEABILITY.md#scoped-local-preparation--inc-qml-53) +records the initial broad dependency failure, four failing counter regressions +before correction, 145 passing corrected consumer/membership cases, 265 passing +installer cases with five exclusions and 138 passing built-wheel cases without +skips. Source-byte attestation, scope ceilings and AST-only refresh pass. +Unchanged full-fork hosted proof remains historical; the upstream PR owns fresh +validation and review. + +The complete native source gate passes 9,202 cases and finds 45 shell-setup +failures, all outside Qt/QML assertions; 122 exclusions remain recorded. Configure +complete checksum-verified portable Git Bash for the test process and rerun the four affected +unchanged modules, including shell-dependent exclusions. This environment +correction adds no product contract or new increment; preserve the failed full +run separately from corrected focused evidence. Fresh normal upstream validation +continues to own the full supported hosted matrix. +The corrected four-module selection passes 171 tests without failures and retains +eight intentional Windows exclusions. The complete portable runtime and exact +Python/Node transport are admitted; no assertion or production change is needed. +The exit review identifies no further reproduced source defect or new increment. + +Exit with focused retained consumer/installer checks, generated-guidance, +frozen packaging, document/identifier/privacy checks, scoped graph refresh, +explicit push and an attached upstream PR. Record exact source/base/checkouts +and normal CI outcomes; a required maintainer workflow approval remains an +explicit external handoff. Review newly exposed failures without weakening +assertions. No reproduced defect is assumed merely from the smaller scope; +INC-QML-54 and REQ-QML-022 remain unallocated until a concrete need exists. diff --git a/docs/qt-qml/PLATFORM_MATRIX.md b/docs/qt-qml/PLATFORM_MATRIX.md new file mode 100644 index 0000000000..cb102b9c15 --- /dev/null +++ b/docs/qt-qml/PLATFORM_MATRIX.md @@ -0,0 +1,200 @@ +# Qt/QML platform and artifact proof + +## Upstream submission scope + +The [scoped upstream contribution](UPSTREAM_CONTRIBUTION.md) retains bounded Qt/QML analysis +and its source, incremental, publication and consumer contracts. REQ-QML-019 +and REQ-QML-021, D12/D16, and the generic viewer portions of INC-QML-09/13/16 +are deferred for this submission. Native Codex REQ-CORE-001-AC01/AC05 and the +broad fork repository policy are also deferred; the baseline installer and +repository policy apply. Constructor and source-containment work in INC-QML-13 +remains in scope. + +The [complete frozen fork](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4) +and its recorded 8b6c9d2 source/runner evidence retain their own revision scope. +Deferred feature sections and their exact tests below are historical evidence, +not implemented or verified contracts of this narrower contribution. A changed +contribution head requires fresh applicable checks; prior totals, skips and +hosted results do not establish those checks. + +This is a delivery matrix for [REQ-QML-001 and REQ-QML-014](../REQUIREMENTS.md), +not a claim that all Qt runtime behavior is statically resolvable. The project +declares Python `>=3.10`; the twelve advertised CI lanes below are the narrower +tested matrix. Python 3.11 is not a declared lane in this matrix. + +The optional parser is `tree-sitter-language-pack==0.11.0` selected by +`graphifyy[qml]`. Record the Graphify, Python, tree-sitter and grammar/package +versions printed by each installed-artifact run. Parser origin/license and +selection evidence remain in [PARSER_DECISION.md](PARSER_DECISION.md). Qt 6.5/6.8 +are source-syntax targets, with a bounded literal Qt 6 qmake metadata profile; +these jobs do not install a Qt SDK, compile or execute analyzed Qt applications. +They therefore cannot prove runtime dispatch, plugin availability or build success. + +## Declared lanes and revision-specific evidence + +| OS runner | Python | INC-QML-03 optional/core wheel result | INC-QML-04/INC-QML-05/INC-QML-06 optional/core result | Final INC-QML-07 result | +|---|---|---|---|---| +| ubuntu-latest | 3.10 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| ubuntu-latest | 3.12 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| ubuntu-latest | 3.13 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| ubuntu-latest | 3.14 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| windows-latest | 3.10 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| windows-latest | 3.12 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| windows-latest | 3.13 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| windows-latest | 3.14 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| macos-latest | 3.10 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| macos-latest | 3.12 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| macos-latest | 3.13 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | +| macos-latest | 3.14 | Passed | Passed at recorded heads | Passed at recorded INC-QML-07 head | + +The INC-QML-03 snapshot is recorded in [VALIDATION.md](VALIDATION.md): reviewed head +`92f31658beceb5d36f570ae8e3820698b39381fb`, base +`0b60d47e6cd9338c51143f39f35b6c45c8453385`, tested synthetic merge +`a9fba56c165210880236a635d00a96e84e19f1aa`. +[Wheel run 37087772653](https://github.com/SlinkyRamey/graphify/actions/runs/37087772653) +passed all twelve lanes. The separate +[CI run 37087772631](https://github.com/SlinkyRamey/graphify/actions/runs/37087772631) +passed Ubuntu full-source suites: Python 3.10 had 6541 passed/16 skipped; +3.12/3.13/3.14 each had 6540 passed/17 skipped. Windows/macOS wheel lanes ran +249 QML source contracts each; Ubuntu wheel lanes ran three artifact contracts +alongside their separate full-source CI jobs. This historical proof covers +INC-QML-03. It does not verify later native Qt, build metadata, analysis stamps or +the new assistant runbooks. + +The subsequent INC-QML-04/INC-QML-05/INC-QML-06 runs completed successfully for their respective +source heads, including all twelve optional/core wheel lanes and the main CI +matrix. These are historical revision-specific results; final INC-QML-07 proof does +not inherit them. + +| Increment | Reviewed source head | Main CI | Optional/core wheel matrix | +| --- | --- | --- | --- | +| INC-QML-04 | `6e454b749b1dbbdfb6d30e31d4f70dee80f2a053` | [37090713564](https://github.com/SlinkyRamey/graphify/actions/runs/37090713564), passed | [37090713525](https://github.com/SlinkyRamey/graphify/actions/runs/37090713525), twelve passed | +| INC-QML-05 | `47b422b9438163fe273bb2ec01e950940420db50` | [37091318091](https://github.com/SlinkyRamey/graphify/actions/runs/37091318091), passed | [37091318124](https://github.com/SlinkyRamey/graphify/actions/runs/37091318124), twelve passed | +| INC-QML-06 | `9fd9cd0d13e601b8e716816020a761484db29b19` | [37092273563](https://github.com/SlinkyRamey/graphify/actions/runs/37092273563), passed | [37092273562](https://github.com/SlinkyRamey/graphify/actions/runs/37092273562), twelve passed | + +Local INC-QML-07 consumer/export/assistant checks have executed; their commands, +skips and current-head limitations belong to [VALIDATION.md](VALIDATION.md). +All twelve implementation-head hosted cells pass. A source-head association does +not claim that the CI checkout is the raw head rather than its recorded synthetic +merge; exact checkout evidence is recorded in VALIDATION.md. + +## Current acceptance procedure + +The authoritative job is [.github/workflows/qml-wheel.yml](../../.github/workflows/qml-wheel.yml), +using [tests/qml_ci.py](../../tests/qml_ci.py). For every declared OS/Python pair: + +1. Build a noneditable wheel from the reviewed source head and identify its SHA256. +2. Create two clean environments: the wheel with `[qml,watch]`, and the core wheel + without the QML extra. Run from a neutral directory with isolated Python `-I`. +3. Run [qml_installed_smoke.py](../../tests/qml_installed_smoke.py). Its audit hook + rejects network and child-process activity during analysis; record optional + parser success, core parser-absence diagnostics and unchanged Python behavior. +4. Run the built-artifact contracts with `GRAPHIFY_QML_TEST_WHEEL` pointing at that + wheel. Windows/macOS also run every `test_qml_*.py`, `test_qt_*.py` and + `test_upstream_qt_*.py`; Ubuntu + runs the full source suite in the main CI job and artifact contracts here. + The wheel environment has `[qml,watch]`; MCP/SVG tests may skip there when their + extras are absent. The main Ubuntu CI installs all extras and exercises those + applicable source contracts. Record skips separately from passing evidence. +5. Record source head, base, tested merge, wheel digest, exact resolved versions, + command, status and skips. An unrun, failed, skipped or cancelled lane is not + passing evidence. Rerun affected lanes after source or dependency changes. + +The latest Windows local proof and baseline portability defects are recorded +separately in VALIDATION.md. A local pass cannot fill an unrun hosted cell. +Runner labels do not promise every CPU architecture, Windows configuration or Qt +version. ARM, Python 3.11, legacy Qt 5.15 semantic profiles, arbitrary Qt SDKs, +runtime-generated QML and dynamic plugin/build execution remain outside this +declared proof unless separately added and verified. + +## Assistant source and generator exception + +Qt/QML assistant guidance originates in the six reviewed source fragments under +`tools/skillgen/fragments/core/` and `fragments/references/shared/`. Generated +skills and `expected/` are regenerated, never edited independently. The AST +runbook reads the existing trusted `.graphify_root`, passes root/cache ownership +explicitly and gates before publishing AST JSON. Qt updates use the production +updater instead of the manual changed-file merge. Scoped AST IDs, bridge evidence +and mechanism distinctions survive semantic augmentation. +Read-only `inspect_qt_analysis` supplies ordered configured import roots and the +immutable native-refresh flag to the AST call. The AST stage does not commit the +analysis checkpoint; graph publication remains owned by CLI/watch. + +`tools/skillgen/gen.py` is an oversized legacy owner, measured at **1450 physical +lines**, with a permitted ceiling of **1450** for this Qt integration. Owner: +Qt integration maintainer. Reason: the frozen monolith validator owns the exact +sanctioned source-guidance line set; moving its baseline policy during this change +would combine unrelated migration with the new instructions. The exception ends +when an upstream-coordinated extraction moves sanctioned predicates and their +characterization into a focused validator module while retaining frozen SHA, +heading, schema and roundtrip behavior. No arbitrary `[Qt/QML]` prefix is allowed. +[test_qml_skillgen_guidance.py](../../tests/test_qml_skillgen_guidance.py) executes +rendered AST publication and rejects unrelated prose/code drift. + +Run the generator and each validator separately; a combined invocation returns +after its first selected validator and cannot prove the remaining checks: + +```text +python -m tools.skillgen --bless +python -m tools.skillgen +python -m tools.skillgen --check +python -m tools.skillgen --audit-coverage +python -m tools.skillgen --schema-singleton +python -m tools.skillgen --monolith-roundtrip +python -m tools.skillgen --always-on-roundtrip +``` + + + + +## INC-QML-07 completed implementation-head proof + +Reviewed source head `235987b9a72e0353cbc9e8cf2c53c7ccd07fceed`, base `9fd9cd0d13e601b8e716816020a761484db29b19`, PR5 synthetic merge candidate +`bcd4d7b4bf9ac1ce91f9e25957d6599fda75f87d`. [Main CI 37094308786](https://github.com/SlinkyRamey/graphify/actions/runs/37094308786) +and [optional/core wheel matrix 37094308526](https://github.com/SlinkyRamey/graphify/actions/runs/37094308526) +completed successfully for the pull_request event. All twelve OS/Python wheel +lanes and four full Ubuntu source lanes pass. Installed smoke uses neutral +directories, isolated interpreters and an offline audit boundary; native Qt +CMake/QRC exposure/load proof is included. The initial shallow-history wheel run +is a documented failure, not a passing cell. + +This record covers the implementation plus corrected workflow. Documentation-only +follow-up commits are rechecked by the same PR workflows; the +[live PR checks](https://github.com/SlinkyRamey/graphify/pull/5/checks) identify the +current reviewed head. Earlier heads are not substituted for changed source. + +## Final local adoption profile + +INC-QML-11/15/08 and INC-QML-28–38 have reviewed local source and installed proof +at index tree `9f0ae4ea75f47539df8ec8a66f1493b9e7f52b2f`. Windows x64/Python 3.12.14 +uses tree-sitter 0.25.2 and language-pack 0.11.0. The noneditable wheel SHA256 is +`184abd15490322fd6f39b50c341a4da79b6cd19e96cd3f4fe3c68ab4c4d29a97`; all 179 Python +payloads match the reviewed source and installation exactly. The selected installed +suite passes 3,024 tests with 36 historical optional/platform skips; new regressions +do not skip. Actual isolated installed CLI CMake/qmake whole-project and safe-subroot +initial/repeat profiles pass. Source full-suite/type baseline failures remain failed +gates. Exact commands and counts are in [validation](VALIDATION.md#final-adoption-delivery). + +| Current source profile | Result | Evidence boundary | +| --- | --- | --- | +| Local Windows x64, Python 3.12.14, pinned optional parser | Locally Verified | Source/installed bounded analysis, consumers, updates and failure recovery | +| Twelve hosted OS/Python lanes above | Not executed for this delivery | Historical INC-QML-07 results apply only to their recorded revision | +| Native browser/device interaction; live database service | Not executed | Emitted scripts/export guards cannot establish system interaction or service delivery | +| Qt runtime/build/plugin execution | Outside static profile | Analysis executes no corpus or Qt SDK | + +## Current-upstream integrated checkpoint + +The [verified integrated checkpoint](../../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2) +records source 8b6c9d2, exact upstream base 35adf43 and tested synthetic checkout +0863b2b. All twelve declared optional-wheel lanes pass: Windows source profiles +pass 2,406 tests with nineteen exclusions each; macOS passes 2,361 with 64 each; +Ubuntu wheel lanes pass three artifact tests each. All 24 neutral isolated +optional/core smokes pass. Four full Ubuntu suites and focused native Windows +also pass with their individual cases, archive integrity and cleanup retained. + +The preceding local-adoption table remains its historical checkpoint. Current +QML platform suites remain source profiles; their aggregate logs do not establish +per-case wheel-byte attestation. Exact counts, source/native artifact evidence, +failed advisory scans, baseline typing and physical interaction/runtime limits +belong to the canonical checkpoint. Documentation follow-up revisions retain +their own normal PR validation through the [PR checks](https://github.com/SlinkyRamey/graphify/pull/7/checks). diff --git a/docs/qt-qml/QT_API_COVERAGE.md b/docs/qt-qml/QT_API_COVERAGE.md new file mode 100644 index 0000000000..6c40ef8eb1 --- /dev/null +++ b/docs/qt-qml/QT_API_COVERAGE.md @@ -0,0 +1,125 @@ +# Qt API mechanism coverage + +Status: bounded correction inventory, not complete Qt API support. Baseline inventory implementation: +`95adbdc165f44a96bf275a7870bb1da4d82a5bea`. Qt's +[combined function and macro index](https://doc.qt.io/qt-6/functions.html) is a +family checklist. Acceptance tests mechanisms that change graph meaning, rather +than testing every indexed function independently. + +## Inventory and evidence boundary + +On 4 October 2026 the moving index identifies Qt 6.12. This comparison pins Qt 6.8 +contracts; existing syntax fixtures also include Qt 6.5. The downloaded index has 15,757 rows, +15,749 distinct displayed names and 33,972 owner-document links. Those are not +overload counts, supported API counts or executed tests. HTML SHA-256: +`5614dd3bcbaf3f2ddca346b6aecc1859bc7b7d6e26c3620bc81280328683e368`. +The complete local inventory remains ignored audit evidence. + +Every indexed name has a policy route; every overload has not been reviewed. +Ordinary calls/declarations use generic source extraction where admitted syntax +supports it. External Qt targets without accepted SDK declarations can remain +unresolved. This does not assert exact overloads, Qt semantics or runtime effects. +Unknown macros may affect parsing and cannot be presumed harmless or supported. + +Use four classes: **explicit semantic support**, **explicit conservative +exclusion**, **generic extraction only**, and **unverified/omitted**. A family can +contain several classes. Token normalization, retained text and a name whitelist +do not establish semantic acceptance. + +## Mechanism matrix + +| Mechanism / criteria | Current semantic handling | Partial, excluded or omitted forms | +| --- | --- | --- | +| Signals, slots, emission and connections; REQ-QML-016 | Explicit event facts, supported member pointers, overload selectors/casts, lambdas/functors, SIGNAL/SLOT and literal flags | Source-local alias authority is corrected in INC-QML-19; qualified source identities are corrected in INC-QML-21; INC-QML-11 adds bounded public ancestor lookup with canonical declaring-member, shadowing, diamond and access rejection; INC-QML-28 retains explicit unknown emission sites. Const/non-const selector spellings do not prove qualifier-aware overload identity. QPrivateSignal reflected signatures, auto-connect by name and QMetaMethod endpoint flows need coverage. Runtime delivery/thread behavior is not modeled. | +| Properties and reflection; REQ-QML-008/017 | Q_PROPERTY facts, selected READ/WRITE/RESET/NOTIFY links, property/setProperty, QQmlProperty read/write and literal invokeMethod | Receiver ownership is corrected in INC-QML-17; static SDK API identity is corrected in INC-QML-22/24. Native property handlers use accepted NOTIFY signals in INC-QML-27. Retained BINDABLE attributes do not establish generated-accessor/dependency semantics. QMetaMethod::invoke and metaObject/index/property handle chains are omitted. | +| QObject lookup; REQ-QML-017 | Root handles and literal objectName/findChild routes | Accepted receiver subtree/depth is corrected in INC-QML-17; unsupported/widget/unknown construction remains unresolved. findChildren is omitted. QML id is not an objectName; runtime reparenting cannot be guessed. | +| Context/initial providers; REQ-QML-008/017/018 | Direct source-established root context and initial-property forms; duplicate/conditional evidence | Exact lexical engines/providers are corrected in INC-QML-18, including component loadUrl association in INC-QML-20. INC-QML-08b adds accepted one-pointer/lvalue-reference declared factory/member APIs and bounded typed child-service calls/subscriptions. Original operator/CV/owner/accessor/NOTIFY/callback proof is required; runtime conversion, allocation and const-correct invocation remain unproved. Unknown aliases, assignment and expired/conditional lifetime remain conservative exclusions. | +| Declarative registration; REQ-QML-008 | Seven allowed macro names: QML_ELEMENT, QML_NAMED_ELEMENT, QML_ANONYMOUS, QML_SINGLETON, QML_UNCREATABLE, QML_ADDED_IN_VERSION, QML_REMOVED_IN_VERSION | Bounded combinations/version forms only. FOREIGN, EXTENDED, ATTACHED, EXTRA_VERSION and other unknown modifiers paired with a recognized base marker are explicitly unsupported. Standalone QML_INTERFACE/QML_VALUE_TYPE and namespace/value/container admission are missing. | +| Procedural registration; REQ-QML-008 | Five name routes: qmlRegisterType, qmlRegisterUncreatableType, qmlRegisterSingletonType, qmlRegisterSingletonInstance, qmlRegisterAnonymousType, with bounded template/literal arguments; explicit-template singleton factory callbacks can establish declared type | Name support does not cover every overload. Other discovered qmlRegister calls retain unsupported reasons. URL, QJSValue callback without explicit type, inferred-template, extended/revision/module/import/metaobject forms are outside the accepted subset. Callback execution/result/lifetime is not modeled. Source-local simple aliases are corrected in INC-QML-19; header alias targets remain excluded; qualified producer IDs are corrected in INC-QML-21. | +| Loading/creation; REQ-QML-017 | load/loadFromModule/setSource, rootObject/rootObjects, create/createWithInitialProperties, initial properties and literal-URL QQmlComponent construction | Literal engine URL constructors and component loadUrl/create are corrected in INC-QML-20, including declaration-owned engine providers and bounded URL/overload authority. Engine module constructors/loadData, component setData/beginCreate/completeCreate and dynamic source/lifetime remain omitted. | +| Enums, flags, gadgets and namespaces; candidate exposure scope | Some annotation tokens are normalized; Q_GADGET marks a class | Normalized Q_ENUM/Q_FLAG/Q_ENUM_NS/Q_FLAG_NS are not an enum/namespace API bridge. Q_NAMESPACE/_EXPORT and Q_GADGET_EXPORT recognition is missing. Acceptance is required before adding those capabilities. | +| Wrappers and ordinary calls; generic profile | Accepted helper contexts include QStringLiteral, QLatin1String, QString, QByteArray, QUrl and fromLocalFile | Support is context-specific; computed/localized strings do not prove literal targets. External SDK declarations and overloads are not automatically admitted. | +| Build/module/resource metadata; REQ-QML-009/018 | Static literal CMake/qmake, qmldir, qmltypes and qrc with bounded paths/conflict handling | INC-QML-08a accepts bounded current-file literal $$PWD paths and separates tooling/build/analysis roots. Relevant arbitrary expansion, conditions and execution remain excluded. The functions index is not build-hook acceptance. | +| Timers, blockers, deletion and ownership | Generic call/source facts may be visible | singleShot delivery, blockSignals/QSignalBlocker, deleteLater and QML ownership transitions have no runtime model. Separate acceptance is required for an extension. | +| Other Qt modules and test/platform macros | Generic extraction where supported by the existing source parser | No individually verified API/overload or macro-expansion promise. Graphify does not execute QtTest macros, discover SDK internals or run corpus code. | + +## Named macro accountability + +The audit reviews macro mechanisms separately from ordinary C++ syntax. The +following names have explicit producer and ordinary regression assignments. +Recognized/normalized tokens alone are never evidence of the Qt relationship. + +| Macro | Analyzed behavior and boundary | Acceptance / exact evidence | +| --- | --- | --- | +| Q_INVOKABLE | Source member receives the invokable role, exact owner, access, signature and original span. Accepted exposure/member lookup makes it available to QML; annotation alone does not prove registration or runtime invocation. | REQ-QML-008-AC02/AC03; `tests/test_qt_cpp_exposure.py::test_members_properties_and_notify_have_exact_original_evidence`; `tests/test_qt_cpp_syntax.py::test_missing_canonical_ids_are_not_reconstructed` | +| Q_PROPERTY | Source property type and selected READ/WRITE/RESET/NOTIFY links retain owner/spans; an ordinary same-named function cannot become a NOTIFY signal. BINDABLE/generated dependency behavior and arbitrary accessor overloads remain outside the verified profile. | REQ-QML-008-AC02/AC03; `tests/test_qt_cpp_exposure.py::test_members_properties_and_notify_have_exact_original_evidence`; `tests/test_qt_cpp_exposure.py::test_notify_same_name_ordinary_function_is_not_signal` | +| QML_ELEMENT | Source registration requires accepted class identity and build/module membership. Missing/conflicting membership cannot create an arbitrary QML provider; supported related naming/version modifiers and rejected unknown modifiers are listed in the mechanism matrix. | REQ-QML-008-AC01/AC03; `tests/test_qt_project_admission.py::test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints`; `tests/test_qt_project_admission.py::test_qml008_ac03_macro_without_membership_or_wrong_module_does_not_guess` | +| Q_SLOTS / Q_SIGNALS | Access sections retain slot/signal roles, access and original positions. Connections/emissions use their distinct event facts; source declarations do not prove delivery/thread order. | REQ-QML-016-AC01–AC04; `tests/test_qt_cpp_syntax.py::test_access_sections_keep_original_roles_offsets`; `tests/test_qt_signals_slots.py::test_macro_sections_private_meta_slots_and_comments` | +| Q_SLOT / Q_SIGNAL | Per-member annotations retain slot/signal roles separately from ordinary methods and legacy SIGNAL/SLOT string syntax. Correct source owner and endpoint identity remain required. | REQ-QML-016-AC01/AC02; `tests/test_qt_cpp_syntax.py::test_annotations_legacy_calls_and_ordinary_emit_are_distinct` | + +Shared rejection evidence covers comments, quoted/raw literals, preprocessor +definitions, malformed/partial source and original Unicode/CRLF spans in +`tests/test_qt_cpp_syntax.py`. Related Q_OBJECT, Q_GADGET, emission and registration +families appear in the mechanism matrix. Q_ENUM/Q_FLAG normalization is not a +verified enum bridge; unknown/foreign/extended/attached registration modifiers +retain explicit exclusions. The audit does not claim every macro expansion, +wrapper, conditional compilation branch or Qt SDK version is implemented. + +## Owners and official contracts + +`qt_cpp_syntax` owns normalization; `qt_cpp_exposure` owns annotations; +`qt_cpp_events`/`QtEventIndex` own native event endpoints; +`qt_cpp_registration` owns registration admission; `qt_cpp_access`/ +`QtQmlAccessIndex` own loading and access; `qt_context_bindings` owns provider +joins; `QtMemberViews` admits property/member roles into the QML bridge. +[CODE_REFERENCE.md](CODE_REFERENCE.md) records the extension seams. + +Reviewed Qt 6.8 contracts: +[QObject macros](https://doc.qt.io/qt-6.8/qobject.html#macros), +[properties](https://doc.qt.io/qt-6.8/properties.html), +[QMetaObject](https://doc.qt.io/qt-6.8/qmetaobject.html), +[QMetaMethod](https://doc.qt.io/qt-6.8/qmetamethod.html), +[registration macros](https://doc.qt.io/qt-6.8/qqmlintegration-h.html), +[registration functions](https://doc.qt.io/qt-6.8/qqml-h.html), +[application engine](https://doc.qt.io/qt-6.8/qqmlapplicationengine.html), +[component](https://doc.qt.io/qt-6.8/qqmlcomponent.html), and +[context](https://doc.qt.io/qt-6.8/qqmlcontext.html). These define Qt behavior; +repository tests establish Graphify's evidence. + +## Acceptance strategy + +For each admitted mechanism test hand-checked success, rejection, ambiguity, +receiver/lexical scope, comments/literals, malformed input and original-byte spans. +Check identity, mechanism/direction and provenance through extraction, build, +persisted reload, query/affected and cold/warm/manual/watch. Include stale-edge +removal, actual failure retention and corrected retry. Reuse mechanism tests for +ordinary members; test exceptional APIs that take distinct production paths. + +INC-QML-17–20 correct A10–A13 and bounded literal loader provenance with recorded +source/artifact evidence. INC-QML-21–27 extend the corrected source profile; final artifact/contribution evidence is recorded in validation. INC-QML-08/11/15 have implemented bounded source profiles; current installed and contribution evidence is assigned in validation. INC-QML-29 adds canonical reference-return callables; INC-QML-30/31 govern logical affected direction and serialization integrity. Other omitted +families are explicit scope candidates requiring acceptance and compatibility +decisions before admission, rather than promises to model the whole SDK. +Update this matrix with newly accepted mechanisms or changed exclusions and bind +each behavior to an existing or new stable requirement and independent evidence. + +## Explicit exposure-chapter review + +The [C++ attribute chapter review](EXPOSURE_CHAPTER_REVIEW.md) maps property, +NOTIFY, signal, slot, invokable and provider mechanisms to existing acceptance +IDs. INC-QML-27 corrects native property handlers and explicit parameter authority. +INC-QML-21/22/24 correct source and SDK identity; INC-QML-23/25/26 correct retained +publication and complete-refresh cleanup. These implemented bounded forms +supersede the earlier finding dispositions without erasing their baseline evidence. +List/gadget/value ownership, runtime overload dispatch and method rebinding remain +explicit semantic exclusions or unverified forms. Macro spellings do not establish +those additional mechanisms. + +## Final adoption evidence boundary + +The current bounded Windows source and reviewed installed profile includes +INC-QML-11/15/08 and INC-QML-28–38. Mechanism proof and per-occurrence transport +survive assembly/serialization/reload; typed uses/read/call/connect/disconnect +facts retain their original endpoint roles and direction. Local installed +acceptance, actual CMake/qmake CLI profiles and retained full-gate/platform limits +are recorded in [final validation](VALIDATION.md#final-adoption-delivery). +Earlier revision records do not establish whole-SDK or runtime compatibility. diff --git a/docs/qt-qml/README.md b/docs/qt-qml/README.md new file mode 100644 index 0000000000..2d83da9581 --- /dev/null +++ b/docs/qt-qml/README.md @@ -0,0 +1,104 @@ +# Qt and QML support foundation + +The [upstream contribution](UPSTREAM_CONTRIBUTION.md) defines the current +submission scope. Earlier full-fork checkpoints remain revision-specific; +independent repository policy, native hooks and general viewer features are +deferred. Qt source payloads and membership-edge accounting remain supported. + +INC-QML-00 through INC-QML-07 implement optional QML declarations/imports, scoped +bindings/aliases/JavaScript/handlers, native Qt C++ signals/connections/slots, +registered C++ APIs and literal QML object access. See [implemented scope](IMPLEMENTATION.md) +and [validation](VALIDATION.md) for individual gates and revision-specific evidence. +Literal CMake/qmake/resource/type-description admission and configuration/update +parity are implemented. INC-QML-07 completes consumer/export/assistant support and the declared hosted +source/artifact matrix; see the exact revision evidence in VALIDATION.md. + +The goal is to add reliable, local analysis of Qt/QML projects to Graphify and +contribute that support upstream in small pull requests. Graphify keeps its +existing Python implementation, graph contracts, CLI, and packaging workflow. + +The audited baseline is the official +[Graphify-Labs/graphify](https://github.com/Graphify-Labs/graphify) repository, +active development branch `v8`, commit +`0b60d47e6cd9338c51143f39f35b6c45c8453385`, package version `0.9.74`. +The checkout was fetched on 2026-10-03. A moving branch or newer release must be +audited again before carrying these conclusions forward. + +| Document | Purpose | +| --- | --- | +| [Audit](AUDIT.md) | Observed extension points, gaps, and risks in the baseline | +| [Upstream contribution](UPSTREAM_CONTRIBUTION.md) | Current submission scope, overlapping proposal and verification boundaries | +| [Follow-up audit](FOLLOWUP_AUDIT.md) | Current scope defects, public reproductions and contribution compliance | +| [Qt API coverage](QT_API_COVERAGE.md) | Mechanism-based support, exclusions and unverified API families | +| [Native navigation review](VIEWER_SYSTEM_REVIEW.md) | Physical browser/device acceptance procedure and evidence gap | +| [Architecture](ARCHITECTURE.md) | Implemented boundaries, future design, support matrix, and ADRs | +| [Design](DESIGN.md) | Implemented source-fact, resolver, Qt and publication contracts | +| [Requirements](../REQUIREMENTS.md) | Observable acceptance criteria and status | +| [Increment plan](PLAN.md) | Ordered, independently reviewable feature increments | +| [Development](DEVELOPMENT.md) | Environment, GitHub workflow, verification, and upstream delivery | +| [Code reference](CODE_REFERENCE.md) | Implemented owners/APIs and compatibility boundaries | +| [Platform matrix](PLATFORM_MATRIX.md) | Revision-specific install and hosted evidence | +| [Export matrix](EXPORT_MATRIX.md) | Semantic transports, presentation views and explicit omissions | +| [Diagnostics](ERRORS.md) | Source, coverage, transport and publication failure contracts | +| [Validation](VALIDATION.md) | Commands actually run and their results | +| [Test traceability](../../tests/TRACEABILITY.md) | Individual acceptance evidence, ownership and remaining gaps | + +Repository development rules are in [AGENTS.md](../../AGENTS.md), together with +the existing [contribution policy](../../CONTRIBUTING.md). The root +[ARCHITECTURE.md](../../ARCHITECTURE.md) remains the upstream system description; +this directory records the local Qt/QML extension and its remaining design. + +The agreed first target is **Qt 6 with both CMake and qmake metadata support**. +Qt 6.5 and 6.8 are source fixture profiles, not claims of installed SDK/runtime +equivalence. The [platform matrix](PLATFORM_MATRIX.md) records successful INC-QML-03 +through INC-QML-07 hosted Linux/Windows/macOS lanes at their respective heads. +INC-QML-07 has local and reviewed-head hosted evidence for that declared matrix. +Qt 5.15 is later, separately verified work. + +Qt signals, slots, emissions and `QObject::connect` are explicit requirements, +as is bidirectional QML/C++ integration: C++ APIs supplied to QML and C++ access +to QML-created objects, signals, properties and methods. The current catalog has +21 Qt/QML requirements and 85 assigned acceptance criteria, alongside four shared +requirements and 14 criteria, with individual traceability. INC-QML-17–48 include +completed corrections and reviewed runner evidence. Current-upstream integration +and the followed-metadata provenance correction are INC-QML-49/50; acceptance of +that combined revision requires its own evidence. + +The [INC-QML-00 decision](PARSER_DECISION.md) selects the optional, pinned language-pack +adapter. The source analysis requires no Qt runtime, compiler or JavaScript +execution. Scope and unresolved-coverage limits are in [architecture](ARCHITECTURE.md). + +The plan retains INC-QML-00 through INC-QML-07, with INC-QML-04a/INC-QML-04b/INC-QML-04c separating exposure, +native events and reverse object access. Native C++ event work can follow INC-QML-00 +alongside the QML lane; metadata readers can follow INC-QML-02. Every acceptance ID +has an individual evidence or remaining-gap assignment in traceability. Early graph/persistence/update +safety is required when a capability is enabled, before later optimization. + + +Use project-root updates for Qt scoped facts. `GRAPHIFY_QML_IMPORT_ROOTS` accepts +an ordered project-relative JSON list for lookup within the accepted corpus. +CLI/watch and the generated assistant AST stage inspect this configuration; +only the graph publication owner commits the analysis checkpoint. Static literal +metadata never invokes a build or runtime engine. The [export matrix](EXPORT_MATRIX.md) +states which consumers retain complete facts, which omit them, and which live +service checks remain unexecuted. + +## Historical community navigation + +The complete fork checkpoint implements the following navigation behavior. +REQ-QML-019/021 are deferred from the upstream contribution, which retains +upstream community selection/navigation and Qt metadata/omission display. + +The frozen HTML export recovers missing large-graph communities locally and fills names +from structural hubs. Select All starts checked and displays all exported view +data. Large source graphs still use the complete labeled community aggregate +within the display cap. Community filters, search, Select All and Select None +remain available. Middle-button dragging pans the graph; the wheel changes zoom. +The full source graph remains available for scoped queries. +Aggregated HTML states its source-detail omissions; camera/filter state is +temporary. Checked-default startup is locally verified through exporter/CLI, +emitted-script and reviewed installed-artifact checks. Browser visual inspection, +other-platform and hosted checks are not claimed for this revision. +See [REQ-QML-019](../REQUIREMENTS.md#req-qml-019--readable-large-graph-html-export) +and [INC-QML-09](PLAN.md#inc-qml-09--readable-large-graph-html-export) for acceptance +and the exact local evidence boundaries. diff --git a/docs/qt-qml/UPSTREAM_CONTRIBUTION.md b/docs/qt-qml/UPSTREAM_CONTRIBUTION.md new file mode 100644 index 0000000000..758cb5913e --- /dev/null +++ b/docs/qt-qml/UPSTREAM_CONTRIBUTION.md @@ -0,0 +1,140 @@ +# Qt/QML upstream contribution + +## Scope and revision ownership + +The contribution adds bounded Qt 6/QML static analysis to Graphify's Python +pipeline. It targets upstream `v8` at +`35adf432b9d50f6f3d530ab5d7ec316819ef081c` (package 0.9.76). +The upstream PR records its exact source head, integration checkout and CI runs. + +The complete fork checkpoint remains available at +[`82a4f29`](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4). +Its nineteen passing jobs prove that recorded integrated revision. They do not +verify the narrower contribution after scope changes. The contribution uses a +separate `codex/qml-upstream-contribution` branch in the same workspace and +preserves the earlier integration branch and history. + +The contribution includes QML declarations and JavaScript scopes, module/member +resolution, supported native Qt declarations and event mechanisms, both QML/C++ +integration directions, literal project/resource/type metadata, safe incremental +refresh, and preservation of those facts through graph consumers. Supporting +source-identity, direction, cache and atomic-publication changes remain because +these contracts depend on them. Optional parser packaging, generated Qt guidance, +regression fixtures and applicable platform validation accompany the feature. + +Independent repository policy, Windows Codex hook serialization, and general +HTML community recovery/navigation changes are deferred to separate +contributions. Upstream installer and generic HTML selection/navigation behavior +remain their baseline contracts. Qt HTML metadata, tooltips and truthful +aggregate source-edge counts remain: REQ-QML-020's packaged-component links need +that count projection. Counts supplement the existing degree display; they do +not introduce partition recovery or a new selection mode. + +| Contract | Contribution disposition | +| --- | --- | +| REQ-QML-001–018 and REQ-QML-020 | Qt/QML analysis scope; prior evidence remains revision-bound and the scoped head requires fresh applicable verification | +| REQ-QML-019 and REQ-QML-021 | General viewer features deferred; the source-count portion of REQ-QML-019-AC02 remains a REQ-QML-020 dependency, without completing that broader criterion | +| REQ-CORE-001-AC01/AC05 | Independent native hook feature deferred; historical fork evidence is not acceptance of the baseline installer | +| REQ-CORE-001-AC02–AC04; REQ-CORE-002–004 | Retained upstream compatibility, source-integrity prerequisites and applicable test infrastructure; named profile assignments exclude the deferred features | + +No requirement identifier is renumbered, reused or silently weakened. The +[requirements](../REQUIREMENTS.md) and [traceability](../../tests/TRACEABILITY.md) +distinguish retained behavior, deferred contracts and source-specific proof. + +## Related upstream implementation + +The contribution addresses the analysis mechanisms requested in +[#1716](https://github.com/Graphify-Labs/graphify/issues/1716) and overlaps with +the open proposal [#1748](https://github.com/Graphify-Labs/graphify/pull/1748). +The submission review inspected that proposal at +`7b38d4c2e2226b1db826a26774c7a5f299b8d62f` on 5 October 2026. + +Both proposals admit QML source and address objects, members, imports, Qt +declarations and C++ registration aliases. This contribution has additional +scoped module/provider authority, literal CMake/qmake/QRC/type metadata, supported +native connection/emission mechanisms, reverse QML-object access and +incremental/persistence/consumer contracts. Its optional parser is pinned +`tree-sitter-language-pack==0.11.0`, shared with Graphify's existing R/Erlang +extras. The inspected #1748 manifest instead uses `tree-sitter-qmljs>=0.3.1`; +its earlier Git-install description is historical. + +The extractor, facade, engine and packaging files overlap. Combining the +proposals needs an explicit maintainer integration decision; neither proposal +is treated as absent work or automatically replaced. The source comparison is +not fresh execution of #1748 or certification of its contributor-reported tests. + +## Review order and evidence + +Review the parser and source-fact contracts first, then QML resolution, native +Qt events, metadata and bidirectional bridges, followed by incremental ownership, +publication and consumers. [CODE_REFERENCE.md](CODE_REFERENCE.md) maps those +owners; [DESIGN.md](DESIGN.md) records identity, direction and persistence rules. + +The scoped HTML exporter has 717 physical lines within a 760-line ceiling. Its +owner retains the upstream embedded template/aggregate boundary while adding +Qt metadata. Extract a characterized template or aggregate responsibility +before further growth. The CLI has 5,068 lines within its existing 5,090-line +ceiling; its owner retains dispatch and must characterize path-command extraction +before further growth. Upstream installer and exporter test bodies are restored; +the 1,617-line install suite retains its 1,640-line ceiling and only the portable +Hermes destination assertion differs from upstream. These current measurements +supersede frozen viewer/hook file-size measurements for this contribution. + +The first broad local scope run passed 2,379 cases with 38 exclusions and found +one test dependency on the deferred generic inspector harness. The retained +Qt membership criterion still requires two external aggregate links and exact +internal/external source-edge accounting. Its correction uses the upstream +production inspector harness, preserves those assertions, and removes only the +startup-selection assertion assigned to deferred REQ-QML-019-AC04. Focused +corrected evidence and the new hosted run belong to traceability and the PR. + +The corrected five-module consumer/membership selection passes 145 tests with +zero skips. Installer compatibility passes 265 tests with five capability/profile +exclusions. The final fresh wheel passes 138 platform/packaging/artifact cases +without skips; all 182 packaged Python files match the working-source bytes and +the three deferred modules are absent. Its SHA-256 is +`c9b413aac68289a2055baf1d70f7efda3fb1b8a4554ad7bb81b0ef6c94e90439`. +These packaging and boundary checks are local evidence, not hosted acceptance. +Fresh offline optional/core installations of that wheel also pass the isolated +production smoke on Windows/Python 3.12.14 from a neutral directory. The optional +profile executes QML extraction and native bridges; core-only reports the +documented missing-parser diagnostic while preserving unrelated extraction. +Whole-project explicit-interpreter Pyright still reports 605 errors and zero +warnings; it is not a clean typing gate. +Its file/severity/rule/message signature multiplicities are unchanged from the +recorded integration checkpoint. + +The complete native source run reports 9,202 passed, 45 failed and 122 skipped. +All 45 failures select the inaccessible WindowsApps Bash alias or lack `sh` in +the test-process PATH, across hooks, shell admission and generated-path tests. +Within that run, all 137 Qt/QML modules report 2,392 passed, zero failed and 38 +skipped. The incomplete bundled shell also leaves seven DLL/mapper setup failures +in its first focused retry. Complete portable Git for Windows 2.56.0 supplies +the required shell/mapping runtime; its official archive SHA-256 is +`eceb5e061aa90df2f69ddd3e90f0030e1b8037a7829934bc40e4be1caa1accc1`. +This local environment correction changes no production or assertions. +The affected-module rerun is a separate +result; it does not turn the original whole run into a passing run. +With the complete CI-compatible isolated/offline profile, four unchanged shell +modules pass 171 tests with zero failures and eight intentional Windows +exclusions. The profile uses Python 3.12.14, Bash/sh 5.3.15, cygpath 3.6.10, +Git 2.56.0.windows.1 and Node 24.19.0. All original failed shell cases are covered +by this corrected selection; the eight skips remain explicit applicability gaps. + +Before publication, run focused retained Qt/consumer and installer compatibility +checks, generated-guidance checks, frozen packaging and document validation. +The normal upstream PR event owns the new hosted run. Fork runs and upstream +runs are recorded separately; maintainers may need to approve execution for a +first-time contributor. A green fork run does not establish upstream execution, +maintainer approval, a merge or a released package. + +Whole-project typing and advisory security findings remain documented baseline +limitations. Skips remain exclusions with exact reasons. Source analysis does +not execute the corpus or require a Qt SDK; static facts do not prove runtime +signal delivery, object lifetime, thread safety, plugin availability or every +Qt API's semantics. Native browser/device/application checks remain separate. + +Implementation and review used Codex. New contribution commits include the +repository's Codex co-author trailer. Seven historical commits lack that trailer; +the [audit](FOLLOWUP_AUDIT.md#contribution-and-collaboration-compliance) retains +the omission rather than rewriting published history. diff --git a/docs/qt-qml/VALIDATION.md b/docs/qt-qml/VALIDATION.md new file mode 100644 index 0000000000..6cad568aa2 --- /dev/null +++ b/docs/qt-qml/VALIDATION.md @@ -0,0 +1,2248 @@ +# Qt/QML validation record + +## Upstream submission scope + +The [scoped upstream contribution](UPSTREAM_CONTRIBUTION.md) retains bounded Qt/QML analysis +and its source, incremental, publication and consumer contracts. REQ-QML-019 +and REQ-QML-021, D12/D16, and the generic viewer portions of INC-QML-09/13/16 +are deferred for this submission. Native Codex REQ-CORE-001-AC01/AC05 and the +broad fork repository policy are also deferred; the baseline installer and +repository policy apply. Constructor and source-containment work in INC-QML-13 +remains in scope. + +The [complete frozen fork](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4) +and its recorded 8b6c9d2 source/runner evidence retain their own revision scope. +Deferred feature sections and their exact tests below are historical evidence, +not implemented or verified contracts of this narrower contribution. A changed +contribution head requires fresh applicable checks; prior totals, skips and +hosted results do not establish those checks. + +This chronological record preserves evidence for each tested revision. Current +INC-QML-49–52 integration uses package 0.9.76, schema 13 and policy 22. Its +[verified integrated checkpoint](../../tests/TRACEABILITY.md#verified-integrated-hosted-checkpoint--8b6c9d2) +binds source 8b6c9d2, exact base 35adf43 and actual checkout 0863b2b to four full +Linux suites, native Windows, twelve wheel lanes and their evidence limits. +Earlier +hosted results at `10cb15a` are in the +[verified runner checkpoint](../../tests/TRACEABILITY.md#verified-hosted-runner-checkpoint--10cb15a): +all four full Linux jobs, native Windows and twelve optional-wheel lanes pass, +with exact revision binding. Source/native jobs retain artifact, individual-case, +integrity and cleanup evidence; optional-wheel lanes retain logs, installed smoke +outcomes and platform source-profile counts, without per-case artifact attestation. +Earlier failure and pending-hosted statements describe their original checkpoints. Baseline typing, +advisory security and separate application/device procedures remain distinct. + +Recorded native source and installed-consumer results at `463ca32` are in +[traceability](../../tests/TRACEABILITY.md#current-native-source-and-artifact-evidence--2026-10-05): +8,818 full-suite passes with no pytest failures, plus separate setup replays. +Older failure totals are historical checkpoints. The +[shared compatibility procedure](../COMPATIBILITY.md#hosted-runner-proof-procedure) +assigns remaining real Linux/service proof. The first CI configuration failed +admission before creating jobs; the optional-wheel workflow passed Ubuntu and +failed Windows/macOS native smoke. The workflow correction passes actionlint; +the reproduced input-alias correction and its outstanding proof are assigned in +[INC-QML-39 traceability](../../tests/TRACEABILITY.md#canonical-accepted-input-identity--inc-qml-39). +Full typing retains 604 matched baseline errors at the recorded source revision. + +## Canonical accepted-input follow-up — 2026-10-05 + +INC-QML-39 corrects membership admission for real contained aliases without +changing lexical fact provenance, Qt policy 18 or AST schema 12. The unchanged +installed smoke fails before the correction under a real Windows short path. +Ordinary collected alias/membership/lifecycle tests pass 48 cases with seven +symlink-capability skips; the broader frozen Qt/QML selection passes **2,259, +zero failures/errors, nine symlink skips**, in 527.19 seconds wall time. +Source fingerprints, all 197 dependencies and original coverage bytes remain +unchanged. Exact commands, source-map and wheel digests, rejection/failure +assignments and limitations are in +[traceability](../../tests/TRACEABILITY.md#canonical-accepted-input-identity--inc-qml-39). + +A clean source-identical wheel passes ordinary QML and core-only smoke, but a +near-root temporary short alias fails the native provenance guard after shared +facade normalization misclassifies its C++ source as external. Fresh source +reproduces it with and without the offline guard; INC-QML-40 owns this separate +correction. Do not infer complete artifact alias acceptance from the ordinary +smoke or broader source selection. + +Six direct/manual/watch probes also reproduce a persistent path-resolution +diagnostic escape. Updates reject and retain graph, manifest, analysis stamp and +root marker, but diagnostic construction exposes the raw backend body and leaves +later sources unannotated. INC-QML-41 owns ordinary regression promotion and +correction. The current failed hosted gates remain open until corrected runs pass. +Targeted Ruff/Pyright pass; fresh whole-project Pyright remains 604 exactly +matched baseline errors, with none added or removed. + +## Facade provenance and diagnostic corrections — 2026-10-05 + +INC-QML-40 canonicalizes physical source identity before facade containment and +external fallback, including real declaration/definition provenance. Existing +external/native rejection and endpoint-key forms remain intact. Policy 19 +refreshes prior native/Qt products at unchanged source/package versions; AST +schema remains 12. Generic-only older API outputs use an explicit forced rebuild +when repair is required. The focused compatibility profile passes **65 cases, +fourteen symlink-capability skips**, one existing Hypothesis warning. + +INC-QML-41 guards source-context construction independently of a failed join. +Every affected source retains the existing failure code; safe complete relative +labels are at most 160 characters and unsafe/unavailable context is empty. +The focused profile changes nineteen failures into **22 passes with three +symlink skips**. Existing failure/membership/update compatibility passes +**136 cases with ten symlink skips**, one existing Hypothesis warning. Actual +manual/watch failure retains all four product bytes, repaired retry and repeat; +the changed helper's measured statement/branch coverage is complete. + +The combined frozen candidate's fresh 180-module wheel is byte-identical to +source and passes unchanged offline isolated QML/core and real near-root Windows +short-alias smoke. The installed alias, policy-upgrade and diagnostic selection +passes **33 cases with ten symlink-capability skips**, with all 152 loaded Graphify +modules originating in the fresh artifact. Three reviewed-wheel tests pass. Artifact digest, source map, +parser/runtime versions, exact test assignments, overlap and capability limits +are in [traceability](../../tests/TRACEABILITY.md#final-local-alias-artifact-and-contribution-gates). +Whole-project typing remains 604 exactly matched baseline errors, with none +added or removed; this is a failed gate. Current hosted results remain pending. +The plan review allocates no further reproduced implementation gap beyond +INC-QML-41; runner outcomes can create another focused correction if needed. + +## Hosted correction checkpoint — f95366d + +[CI run 37248954702](https://github.com/SlinkyRamey/graphify/actions/runs/37248954702) +and [QML wheel run 37248954699](https://github.com/SlinkyRamey/graphify/actions/runs/37248954699) +use `pull_request`, source `f95366d3f83f5a1c2558e99ff2e0efa294c41f08`, +base `f61827f42378f6e7feff05a1cb4cbbcab1736ca8` and actual synthetic checkout +`7f2e808696edfdb6bac010b80986439ae060216c`. Configuration admission succeeds. + +Ubuntu Python 3.14.8 completes **8,952 passes, two failures, 69 skips**. +Python 3.13 retains the same complete pytest result before its job is cancelled +by matrix fail-fast. Python 3.10/3.12 have no complete JUnit or pytest-exit result; +their cancellation establishes no suite acceptance. Both failures are generic +symlink provenance: distinct target/leaf-source labels collapse, and watched +followed-directory rename loses its lexical source. INC-QML-42 owns the correction +with both original assertions intact. The two real FalkorDB and three reviewed +wheel cases pass individually in each completed pytest profile. Source, checkout, +dependencies and prebuilt wheel remain unchanged there; test graphs are deleted +and all four job-owned containers are confirmed absent. Artifact ZIP digests +match the API digests. Redis is 8.10.2 and graph module version 60001; readiness +and zero mounts are recorded. These are passing service boundaries within failed +source jobs, not an overall passing CI result. + +The native Windows compatibility job rejects before pytest when duplicate Node +applications become a path array. INC-CORE-07 corrects selection; five real native +application/version/rejection regressions pass locally. Dependency/import evidence +exists for the failed setup, but identity, JUnit and integrity evidence is absent. + +All twelve QML lanes pass unchanged isolated optional/core installed smoke. +All four Ubuntu lanes pass three reviewed-wheel cases; all four macOS lanes pass +**2,264 source cases, 48 skips** each. Windows lanes each retain **2,302 passes, +three optional-profile skips and seven nearby-alias fixture setup errors**. +INC-QML-43 corrects the actual multi-ancestor short-path fixture while retaining +its nearby-path precondition and production assertions. QML job logs bind checkout +and outcomes; that workflow retains no wheel digest, JUnit or source/dependency +integrity artifact. This limitation remains explicit rather than being inferred +from the job conclusion. Separate lane totals overlap and are not added together. + +Skill generation, coverage inventory, enum and round-trip checks pass. The existing +security job uses advisory continue-on-error: its green conclusion includes four +high/eight medium Bandit findings and fifteen dependency-advisory rows across +three packages, with repeated advisory identities. It is not a clean security +audit or proof that the findings originate in this change. Gate policy and frozen +dependencies are unchanged. The corrected hosted runs remain pending. + +## Discovered-source and nearby-fixture checkpoint — INC-QML-42/43 + +Actual Windows junction regressions pass nine cases with one file-symlink +capability skip; existing alias/cache/watch compatibility passes 405 cases with +31 capability skips. The corrected nearby short-path fixture passes under real +short-named ancestors. The frozen source-identical 181-module wheel passes +unchanged core/QML smoke and the installed contract selection: **43 passes, +eleven actual file-symlink skips, zero failures/errors/warnings**. Source bytes, +dependency versions and original coverage remain unchanged. Exact artifact +identity, individual acceptance assignments and boundaries are recorded in +[traceability](../../tests/TRACEABILITY.md#discovered-source-identity--inc-qml-42). + +Whole-project Pyright retains 604 exactly matched baseline errors, zero added or +removed; it is not a passing typing gate. These results precede INC-QML-44/45. +Adjacent probes reproduce shared-content watch invalidation and Windows short +leaf-input identity gaps, assigned separately in the increment plan. Corrected +Linux replay and hosted Windows/QML acceptance remain pending. + +## Combined physical-source correction — INC-QML-44/45 + +The frozen native Windows/Python 3.12.14 compatibility selection passes +**640 tests, zero failures, 21 documented capability/profile skips**. All 59 new +co-owner, short-spelling, admission-failure and remap-owner cases pass without +skips. Source/helper sizes, exact criterion assignments, retained exclusions and +prior cache/cohort byte evidence are in +[traceability](../../tests/TRACEABILITY.md#combined-source-and-installed-checkpoint--inc-qml-4445). + +Fresh wheel SHA-256: +`c815eef893aa928eb58287be2b56e41c22b99ba43999e2c3ec30026af82108ac`. +All 182 Python payloads match source and installation. Unchanged isolated +QML/core/full-TEMP short-alias smoke passes; installed contracts pass **102 cases, +eleven file-symlink capability skips, zero failures/errors/warnings**. All 154 +loaded Graphify modules come from that artifact. Source/dependency/coverage bytes +are unchanged; owned junction/temp fixtures are removed. The five separate actual +Windows CI tool tests pass without skips or warnings. + +Both focused helpers have complete measured statement/branch coverage. Ruff, +134 generated artifacts, document/reference checks and actionlint pass; both +complete native CI scripts parse with no errors in Windows PowerShell 5.1 and +pwsh 7.6.6. AST-only repository graph refresh completes without an LLM call. +Full Pyright remains **604 exact baseline errors**, zero added/removed, across +677 current files; the typing gate still fails. Source/installed exit review finds +no further reproduced defect in this scope and leaves INC-QML-46 unallocated. +The next normal PR event owns corrected hosted execution, separately from these +overlapping local profiles and unexecuted physical device/system procedures. + +## Hosted correction checkpoint — 38bf8cc + +[CI run 37253601867](https://github.com/SlinkyRamey/graphify/actions/runs/37253601867) +and [QML wheel run 37253601849](https://github.com/SlinkyRamey/graphify/actions/runs/37253601849) +use normal `pull_request` events, source +`38bf8cc12fc84f6b43f25b150a6ba5a16b4ecdd2`, base +`f61827f42378f6e7feff05a1cb4cbbcab1736ca8` and synthetic checkout +`ec2b85d07ed02408e81fb2127150393f7b702b64`. Both workflows fail; no manual +dispatch or duplicate feature-push run is used. + +Ubuntu Python 3.14.8 records **8,989 passes, two failures, 107 skips**. Both +failures are the new manual/watch older-policy alias fixture using default +discovery for a followed directory. INC-QML-46 owns explicit profile/interface +correction. The original two Linux provenance regressions and all 27 applicable +new co-owner/remap cases pass individually. Both real FalkorDB and all three +reviewed-wheel cases pass. Checkout, source, dependencies and wheel remain +unchanged. All four retained wheels contain 182 Python payloads byte-identical +to published source; all four artifact archive digests match GitHub API values. +The other three Ubuntu lanes are matrix-cancelled before complete JUnit, exit +and integrity evidence; they are incomplete, not passing. All four disposable +containers are confirmed absent, and the completed lane confirms graph deletion. + +Native Windows records **783 passes, 33 failures, 20 skips**. Ten hook cases and +23 generated-skill security cases launch the WSL stub through a bare Bash process +name despite successful absolute Git Bash preflight. The retained artifact binds +the exact checkout, JUnit, tool versions and unchanged 197 dependencies/source. +INC-CORE-07 selection cases pass; INC-CORE-08 owns the separately reproduced +native shell launch/consumer identity correction. + +All twelve QML lanes pass unchanged installed optional/core smoke. Ubuntu wheel +jobs and all four macOS jobs pass; macOS records **2,264 source passes, 55 skips** +per lane. Each Windows source lane fails the one warm-parser short-parent spy +whose raw-path expectation predates INC-QML-45 normalization. INC-QML-47 preserves +exact owner assertions under the accepted spelling contract. These workflow +logs retain checkout and outcomes but no wheel digest, JUnit or source/dependency +integrity artifact; that limitation remains explicit. + +All 134 generated artifacts and five skill-generation validators pass. Advisory +Bandit retains four high, eight medium and 109 low findings. Dependency auditing +fails before producing an inventory with a remote connection error; that is +unavailable evidence, not a clean or completed vulnerability scan. Existing +advisory policies and frozen dependencies are unchanged. The new focused +corrections require another normal PR event; earlier successful boundaries do +not establish their current complete hosted acceptance. + +## Manual discovery and native shell correction — INC-QML-46/47 and INC-CORE-08 + +The final local source/consumer/artifact profile passes its assigned corrections: +70 focused cohort/discovery cases (two capability/profile skips), 253 broader +CLI/watch cases (five skips), 161 shell/hook/skillgen cases (eight profile skips), +and nine membership cases (seven actual file-symlink skips). Exact commands and +individual acceptance assignments belong to +[traceability](../../tests/TRACEABILITY.md#manual-discovery-and-native-fixture-contracts--inc-qml-4647). +The counts overlap; they are not a full-suite result. + +The fresh source-identical 182-payload wheel passes unchanged isolated QML/core/ +full-TEMP short-alias smoke and **118 installed cases, nineteen explicit skips**, +zero failures/errors/warnings. All 69 required new/prior cases pass without skips; +loaded modules originate in the artifact and source/dependency/coverage bytes +remain unchanged. Native explicit shell preflight proves the selected Python +and Node identities. Both workflows pass their syntax checks; the AST-only +repository graph refresh completes. Full typing retains 604 exact baseline +errors with zero added/removed and remains a failed gate. Current corrected +hosted proof remains pending; no merge or protected publication is performed. + + + +## Executed INC-QML-00 through INC-QML-03 — 2026-10-03 + +The imported baseline remains `0b60d47e6cd9338c51143f39f35b6c45c8453385`. +INC-QML-00 commit `2f0fdce`, INC-QML-01 commits `4f587f6`/`63075e8`, and INC-QML-02 +commit `df432a7` separate the executed stages. INC-QML-03 is on +`codex/qml-03-relationships`; its source and final evidence are committed together. +The upstream API was rechecked: v8 still points at the imported baseline. Proposal +#1748 remains unmerged; no proposal code was copied into these modules/fixtures. + +| Executed check | Result | +| --- | --- | +| INC-QML-00 candidate probes, each Windows Python 3.10/3.12/3.13/3.14 | 40 passed, no skips per lane | +| All 21 actual QML test files with reviewed INC-QML-03 wheel, each Windows Python lane | 239 passed, two symlink-permission skips per lane before final eight BOM/CRLF reader regressions | +| Final qmldir reader and production syntax profile | 33 passed, no skips, including original BOM/CRLF/Unicode bytes and bounded binary reads | +| INC-QML-03 optional wheel installs, neutral cwd, isolated `-I` production entry point | Passed Python 3.10.21/3.12.14/3.13.15/3.14.7; bindings, imported scripts and subscriptions exercised with Python network/process audit denial | +| Fresh core-only wheel, Python 3.12.14 | Passed with pack actually absent; Python extraction still works, QML reports parser unavailable | +| Built-artifact tests with GRAPHIFY_QML_TEST_WHEEL supplied | Three passed; artifact imports and core/parser boundary executed | +| New CI helper executed locally, Python 3.12 | Clean extra/core installs and both isolated smoke checks passed; three artifact tests passed | +| Minimal CI environment QML source suite | 239 passed, two symlink skips; pytest 9.1.1, NetworkX 3.7, Python 3.12.14 | +| Relevant existing shared-boundary ten-file suite | 1337 passed, 53 skipped, the same two baseline Windows deleted-cwd failures | +| Whole-repository Ruff | Passed | +| All seventeen QML runtime modules and two install/CI helpers, targeted Pyright | Zero errors, zero warnings | +| skillgen check/audit-coverage/schema-singleton/monolith/always-on round trips | All passed; 134 generated artifacts match | +| uv lock --check | Passed; 210 packages, no unrelated lock churn | + +The shared-boundary command was `python -X utf8 -m pytest` with test_detect, +test_languages, test_extract, test_build, test_cache, test_watch, test_paths, +test_query_mcp_direction, test_extractors_registry and test_validate. The two +collector failures first encountered were stale extension-only expectations: +the corrected tests retain the full legacy suffix oracle and independently require +the exact qmldir fixture. No unsupported extensionless input is newly admitted. + +The two unchanged deleted-cwd failures below still raise WinError 32 before the +tested behavior. The full suite/all-extras hosted CI is not represented by these +focused results. Windows cannot create the symlinks needed by two QML corpus +tests; no skip counts as passing containment evidence. Python 3.10 uses NetworkX +3.4.2 and emits two future warnings; the other fresh lanes use NetworkX 3.7. +Whole-baseline Pyright still has the recorded 634 errors/four warnings. + +CI now has a separate read-only optional-wheel matrix for Ubuntu, Windows and +macOS on all four Python lanes. The existing Ubuntu all-extras job owns source +regressions; its extra QML job runs wheel/core evidence only. Windows/macOS add the +focused QML suite. PR events own validation; no duplicate manual full run was +dispatched. Hosted conclusions remain pending, and Linux/macOS are unverified. +Python-level analysis guards do not constitute an operating-system network sandbox. + +The eight new original-byte regressions invalidate the earlier wheel's qmldir +provenance evidence. Final source-snapshot wheel rebuilding/reverification is +recorded below before published completion; earlier counts remain historical. + +Final INC-QML-03 revalidation: source archive built from the reviewed Git index, +excluding concurrent next-increment modules; optional wheel SHA256 +`b6d98399ad534f9a86a99c8d4f35d18027c7143ee90b1ec72e7e2aaab03bf881`. +All four isolated Windows lanes run the updated installed production smoke, +including BOM/CRLF qmldir bytes, and the complete source/artifact suite: +**247 passed, two symlink-permission skips per lane**. A fresh core-only Python +3.12 wheel also passes the updated smoke. The earlier wheel is superseded. +Repository graph refresh passes: 19,115 nodes, 38,416 edges, 1,070 communities; +existing unavailable optional-language warnings remain explicit in the local log. +Public document links/privacy and all 68 distinct criterion assignments pass. + +## Historical foundation evidence + +Validation date: 2026-10-03. Baseline: +`0b60d47e6cd9338c51143f39f35b6c45c8453385`, upstream `v8`, Graphify `0.9.74`. +Host: Windows x64, CPython `3.12.14`, uv `0.12.15`, Tree-sitter `0.25.2`. +No Graphify runtime source, parser dependencies, lockfile, or existing tests were +changed for this foundation. Results describe the audited baseline. + +## Account and repository setup + +GitHub CLI `2.102.0` was obtained from its official release and the downloaded +archive SHA-256 was checked against the release digest. OAuth sign-in completed +and `gh auth status` reported keyring storage, an active account, HTTPS Git, and +`repo`, `workflow`, `read:org`, and CLI-default `gist` scopes. Authenticated +`GET /user` succeeded. No credential value was copied into repository files. + +A contributor fork was created using the GitHub API and verified as a fork of +`Graphify-Labs/graphify`, default branch `v8`. The repository API returned +`admin`, `maintain`, `push`, `pull`, and `triage` permissions as true for that fork. +The local `origin` targets the fork, `upstream` targets the official repository, +and `remote.pushDefault` is `origin`. This verifies development access to the +fork; it does not claim administration access to the official upstream. + +## Environment + +The machine's default Python command was a Store launcher, and the first managed +Python setup encountered an invalid minor-version link. Neither problem required +a source or lockfile change. The bundled CPython interpreter supplied a working +base for: + +```text +uv sync --frozen --python --no-managed-python +``` + +Result: success; repository-local `.venv` installed locked core and development +dependencies. Optional extras were not all installed. Machine-specific paths and +raw authentication responses are intentionally absent from this public record. + +## Executed checks + +| Command / probe | Result | +| --- | --- | +| `uv run --frozen --no-sync graphify --help` | Passed; CLI starts from the installed source checkout | +| `uv run --frozen --no-sync ruff check .` | Passed | +| `uv run --frozen --no-sync python -m tools.skillgen --check` | Passed; 134 generated artifacts match committed outputs | +| `uv run --frozen --no-sync pyright` | Failed on unchanged baseline: 634 errors, 4 warnings; includes optional missing imports and existing typing errors | +| Foundation document consistency check | Passed; 17 requirements, 68 unique acceptance criteria and 68 individual traceability assignments | +| Foundation local-link/private-reference scan and `git diff --check` | Passed across 11 foundation documents; no matching private identifiers, machine paths or credential patterns | +| Six-file focused suite using `uv run --frozen --no-sync pytest ... -q --tb=short` | 1,259 passed, 53 skipped, 8 failed in 58.90 seconds; runner/encoding differences investigated below | +| Five failed multiprocessing-fallback cases using `python -m pytest` | 5 passed, 232 deselected; module invocation fixes the runner boundary | +| Unicode-normalization case using `python -X utf8 -m pytest` | 1 passed; UTF-8 fixes the host-default text encoding boundary | +| Same six-file suite using `uv run --frozen --no-sync python -X utf8 -m pytest ... -q --tb=short` | **1,265 passed, 53 skipped, 2 failed in 49.62 seconds** | + +The six files were `tests/test_detect.py`, `tests/test_languages.py`, +`tests/test_extract.py`, `tests/test_build.py`, `tests/test_cache.py`, and +`tests/test_watch.py`. They cover the main extension boundaries; this was not the +complete test suite or an all-extras/cross-platform CI run. Skipped optional parser +or watcher cases are coverage gaps, not successful feature verification. + +The two remaining failures are: + +- `tests/test_watch.py::test_rebuild_code_deleted_cwd_without_repo_root_returns_false` +- `tests/test_watch.py::test_rebuild_code_deleted_cwd_uses_graphify_repo_root` + +Both attempt to remove a directory while it is the process's current directory. +Windows raises `PermissionError: [WinError 32]` before the behavior under test is +reached. Preserve these findings as baseline portability debt. A faithful Windows +test adaptation belongs in a separate focused change; the foundation did not +weaken or skip the assertions. + +## Executed file-admission probe + +Called `classify_file(Path(name))` and `_get_extractor(Path(name))` on baseline +filenames. These are classification/dispatch observations, not full parser runs: + +| Input | Classification | Extractor | +| --- | --- | --- | +| `Main.qml` | None | None | +| `Panel.ui.qml` | None | None | +| `plugins.qmltypes` | None | None | +| `qmldir` | None | None | +| `CMakeLists.txt` | Document | None | +| `app.pro` | None | None | +| `assets.qrc` | None | None | +| `backend.hpp` | Code | `extract_cpp` | +| `helpers.mjs` | Code | `extract_js` | + +These observations confirm that QML and key Qt metadata do not enter the current +deterministic extraction path. They do not prove that generic C++ handles Qt +meta-object declarations or that generic JS handles QML-specific script directives. + +## Upstream proposal review and remaining gates + +Read-only API inspection found open issue #1716 and PR #1748. The reviewed PR head +is `7b38d4c2e2226b1db826a26774c7a5f299b8d62f`; see [AUDIT.md](AUDIT.md) for +static reuse/gap findings. Its source was fetched to a remote ref for inspection; +it was not merged or tested as the working implementation. + +QML parser installation/API compatibility, representative Qt version profiles, +grammar error recovery, PR implementation tests, full-suite/all-extras CI, macOS +and Linux extraction, performance, and Qt-specific incremental parity remain +unverified. INC-QML-00 in [PLAN.md](PLAN.md) owns the next investigation. + +## Development-standard review — 2026-10-03 + +[AGENTS.md](../../AGENTS.md) covers twelve discipline areas, GitHub pipeline +sequencing and the specialized upstream extractor-migration constraints. +Protected proof rules remain future policy; the standard does not establish +workflow protection or runtime language support. + +The canonical requirements are in [docs/REQUIREMENTS.md](../REQUIREMENTS.md), +linked from the index, audit, design, plan, code reference and traceability. +A Python document check passed across eleven public foundation files: +all standard headings exist, local links resolve, the old requirements path is +absent, and private-reference/credential-pattern checks pass. All seventeen +requirement IDs and sixty-eight acceptance criteria retain their numbering; +criterion text/order is unchanged and each criterion still has one traceability +assignment. `git diff --check` passed. + +This was a documentation-only review. Runtime tests were not rerun; the baseline +results and outstanding implementation gates above remain unchanged. + +## Increment execution-plan review — 2026-10-03 + +Refined [PLAN.md](PLAN.md) into reviewable packages with explicit prerequisites, +readiness/completion gates, file ownership and evidence handoffs. Native Qt event +work can follow the baseline/contracts checkpoint alongside QML extraction; +metadata readers precede their later bridge joins. Early enabled update paths +require a tested safe fallback or rejection before writes. + +A one-off Python planning check passed across eleven public foundation files: +seventeen stable requirements, sixty-eight unchanged criteria with exactly one +planned completion increment each, and eight preserved increment IDs. The +dependency graph has eleven nodes and sixteen edges with no cycle. All forty-six +referenced existing test paths resolve; the sixteen new Qt/QML test files remain +explicit proposals. Local links, canonical paths, planned/unexecuted status and +private-reference/credential-pattern checks passed. `git diff --check` passed. + +No production code, parser probe or runtime test was executed for this planning +revision. INC-QML-00 remains the first execution checkpoint, and all implementation +acceptance criteria remain unexecuted. + + + + +## INC-QML-03 hosted proof — 2026-10-03 + +Draft [fork PR 1](https://github.com/SlinkyRamey/graphify/pull/1) is open against +`v8`. Reviewed source head `92f31658beceb5d36f570ae8e3820698b39381fb`, base +`0b60d47e6cd9338c51143f39f35b6c45c8453385`; both PR workflows checked out synthetic +merge `a9fba56c165210880236a635d00a96e84e19f1aa`. This is pre-merge evidence; +no default branch was merged. + +[CI run 37087772631](https://github.com/SlinkyRamey/graphify/actions/runs/37087772631) +completed successfully: Ubuntu Python 3.10 had 6541 passed/16 skipped; Python +3.12, 3.13 and 3.14 each had 6540 passed/17 skipped. Ruff, skill regeneration, +security scan and installation checks also succeeded. Skips remain explicit. + +[Optional wheel run 37087772653](https://github.com/SlinkyRamey/graphify/actions/runs/37087772653) +completed successfully in all twelve Ubuntu/Windows/macOS Python 3.10/3.12/3.13/3.14 +lanes. Each lane built and installed the wheel into clean optional/core environments +and ran isolated offline production smoke. Windows and macOS each ran 249 source +contracts; Ubuntu ran the three artifact contracts and the separate CI workflow +owned the full source suite. The hosted Windows symlink cases passed. + +This verifies INC-QML-03 only. Later Qt changes require their own reviewed-head proof. + + + + +## INC-QML-04 local proof — 2026-10-03 + +Reviewed source boundary starts at INC-QML-03 head `92f31658beceb5d36f570ae8e3820698b39381fb`. +Official upstream v8 was rechecked and remains +`0b60d47e6cd9338c51143f39f35b6c45c8453385`. No upstream Qt PR code was copied. + +Windows Python3.12.14 focused QML/Qt+C++/cache/registry suite: **547 passed, +17 skipped** (host symlink permission, artifact env absent in that command, and +baseline optional language omissions). The actual wheel artifact suite separately +passed all3 cases. Whole-repository Ruff and lockcheck (210 packages) passed; +focused runtime/index/helper Pyright had zero errors/warnings. Baseline Windows +current-directory deletion defects recorded in INC-QML-03 remain unchanged limitations. + +A clean Git-index source archive built the noneditable wheel with SHA256 +`a4973256e987d9e75c89e54567350c0ff9af72384b2eed57ff19d173780d972e`. +Python3.12.14 isolated neutral-directory optional/core smoke both passed, including +native Qt emission with no QML parser in the core-only environment. The wheel +contains the internal metadata/index foundation; its public admission remains +INC-QML-05. Later Python/OS source lanes are owned by the forthcoming PR workflows. + +The required repository graph refresh succeeded:19555 nodes,40020 edges and1065 +communities. Existing unavailable optional-language warnings remain explicit. +Generated graph/report/cache/coverage data are excluded from commits. + + + + +## INC-QML-05 local evidence + +Windows Python3.12.14: 435 Qt/QML tests passed with6 documented skips before the +final duplicate-provider regression; extract/registry/admission245 passed8 baseline +optional-language skips. The final canonicalization fix passes independent facade +CMake/qmake native membership, duplicate module, qrc alias and accepted-target +regressions. INC-QML-06 tests still demonstrate ignore-only refresh gaps and remain +open. Ruff passes touched production/tests. The source refresh produced19701 nodes, +40531 edges and1066 communities before the final identity fix; a final refresh is +required before commit. Hosted INC-QML-04 runs37090713564/37090713525 were still running +when this local evidence was recorded; they do not prove the INC-QML-05 revision. + +Final focused public admission/native/resource/type suite: 67 passed. Additional +index regressions retain same-target duplicate project declarations as ambiguous. +No acceptance is inferred from unfinished INC-QML-06 or INC-QML-07 cases. + + + + +## INC-QML-06 local evidence + +Windows Python3.12.14: policy/state/config/provider tests73 passed; the corrected +plain-C++ cache plus config/provider subset52 passed; actual code-only/extract/ +QML publication CLI tests89 passed. An intermediate broad run1001 passed28 skipped +with a corrected plain-header cache regression and the two established Windows +WinError32 deleted-current-directory baseline failures. Final regression and worker +proof follow. Both INC-QML-04 and INC-QML-05 hosted CI and twelve optional/core wheel lanes +completed successfully for their respective PR heads; final INC-QML-07 proof remains. + +Final worker/cache/native-root suite6 passed; compatibility fallback regression +plus worker suite7 passed after preserving the legacy helper invocation. Targeted +Qt policy/state/project/resolver typing reports0errors0warnings; whole Ruff passes. +The last broad regression1001 passed28skipped, with corrected helper invocation +and the two established Windows deleted-current-directory failures. Their hosted +Linux proof remains required; no test or assertion was skipped to mask them. + + + +## INC-QML-07 local final source proof + +Windows Python 3.12.14, actual optional QML/MCP environment: all Qt/QML source +contracts **613 passed, 6 skipped** in 48.69s before the final explicit normal-edit +parity additions. Skips: two host symlink-permission cases, three artifact cases +without GRAPHIFY_QML_TEST_WHEEL in that source command, and one absent optional +SVG renderer. The actual HTTP and separate-process stdio protocol cases ran; +they are not direct tool-function substitutions. The artifact cases run separately +against the reviewed wheel. A Starlette/httpx deprecation warning remains visible. + +Final affected/header-definition/HTML/report/query/HTTP/stdio consumer subset: +27 passed without skips. Export and SDK boundary regressions:83 passed with one +absent optional SVG skip. Search/query/serve regressions:187 passed with one +optional jieba skip. Generic affected/CLI path/query/explain/call-flow regressions: +63 passed. Generic extraction CLI/extraction/cache hooks:336 passed with16 +documented baseline optional-language skips. Whole Ruff and lockcheck (210 +packages) pass; focused new helper/test typing has zero errors and warnings. +Whole-repository baseline Pyright remains634 errors/4warnings; it is not claimed +green. The established Windows deleted-current-directory baseline defects remain +in the historical broad run; no assertion or skip was changed to conceal them. + +Skill/guidance/platform tests:91 passed. Each generator validator ran separately: +--check (134 artifacts), --audit-coverage, --schema-singleton, +--monolith-roundtrip and --always-on-roundtrip all pass. New handwritten Python +files are below300 lines; measured legacy hooks are recorded in IMPLEMENTATION.md. +All exact traceability test references resolve; the seventeen requirement IDs +and sixty-eight acceptance IDs are retained. Public documentation/local-link, +private marker and credential-shaped text review is part of final delivery. + +Final normal-edit parity, reviewed-source wheel digest, clean isolated optional/ +core smoke, graph refresh and exact-head hosted CI remain separately recorded +gates. Earlier INC-QML-03/INC-QML-04/INC-QML-05/INC-QML-06 passes do not verify the INC-QML-07 revision. + +The final real incremental parity additions pass all6 cases without skips. Both +actual manual update CLI and locked watch driver compare normal QML edits and +no-change updates with cold/warm clean extraction/build/JSON. Accepted named IDs +and unrelated Python facts survive. QML member/objectName changes leave C++ bytes +unchanged while removing4 obsolete property/invoke/lookup links and acquiring4 +correct source-backed links; local shadowing removes context exposure. Complete +public facts and logical endpoint direction match clean output. Only derived +indexing/scoring fields are normalized; metadata, spans and confidence remain. + +The source graph refresh succeeded:19890 nodes,41131 edges,1069 communities. +It is navigation data, not acceptance proof. Generated graphs/caches/reports and +local coverage artifacts are excluded from the public change. Final privacy/link/ +stable-ID and new Python footprint review passes. + + + +### INC-QML-07 reviewed artifact and initial hosted correction + +Implementation head `5e73bacf5529f83d014b3709959ebd1784a62593` has reviewed +Git-index tree `0224d13a5eca209030b6c7873ac3fa052248dea0`. Its built wheel +SHA256 is `99c5d1030f8781eecb7bb73834bd27086fa0a6c16ed9a5f814173d230f4f42ed`. +Clean optional/core Python3.12.14 environments pass isolated `-I` offline smoke +from a neutral directory, including native QML_ELEMENT/CMake/QRC integration; +the actual built-artifact suite passes3 cases. Tree-sitter0.25.2 and optional +language-pack0.11.0 are recorded. These local passes do not fill hosted cells. + +Final event mutation/removal additions pass2 cases (manual/watch); combined with +normal-edit/no-change/reverse-access parity,8 pass without skips. An independent +build/source-priority plus final-parity regression subset passes102 cases. +Final navigation refresh:19924 nodes,41264 edges,1095 communities. + +[Draft fork PR5](https://github.com/SlinkyRamey/graphify/pull/5) is stacked on +INC-QML-06 head `9fd9cd0d13e601b8e716816020a761484db29b19`. Initial source head +`5e73bacf5529f83d014b3709959ebd1784a62593` tested merge candidate +`7229b127ee144b35426501467724e3c471c34b20`. +The first [wheel run37094103638](https://github.com/SlinkyRamey/graphify/actions/runs/37094103638) +passed the four Ubuntu installed-artifact lanes but exposed missing historical +Git objects in Windows/macOS source-guidance tests: checkout was shallow and the +frozen pre-split baseline could not be read. The wheel workflow now fetches full +history, matching the existing main CI source-policy job. Tests and frozen guards +are retained. This failed/obsolete run is not counted as final platform proof; +the corrected reviewed revision requires its own complete matrix. + + + + +## INC-QML-07 corrected hosted completion + +Reviewed implementation/workflow head `235987b9a72e0353cbc9e8cf2c53c7ccd07fceed`; stack base `9fd9cd0d13e601b8e716816020a761484db29b19`; +synthetic merge candidate `bcd4d7b4bf9ac1ce91f9e25957d6599fda75f87d`. +[CI 37094308786](https://github.com/SlinkyRamey/graphify/actions/runs/37094308786) and +[wheel 37094308526](https://github.com/SlinkyRamey/graphify/actions/runs/37094308526) +are pull_request runs with successful conclusions. All four full Ubuntu Python +source jobs and all twelve Windows/Linux/macOS Python3.10/3.12/3.13/3.14 optional/ +core wheel jobs succeed. The exact job test/skip and checkout evidence is recorded +below after reading logs; an overall security job with continue-on-error steps +does not establish absence of baseline findings. + +Final review preserves17requirements/68criteria, correct public support limits, +privacy, local links and focused module ceilings. INC-QML-00..INC-QML-07 are complete within +the declared static profile. Documentation-only follow-up changes trigger fresh +PR checks, whose current head is available from +[PR5 checks](https://github.com/SlinkyRamey/graphify/pull/5/checks). +The fork PRs remain draft/unmerged; upstream acceptance and package release have +not happened. No additional increment is required for the agreed initial scope. + +Direct inspection of all16 test job logs confirms actual checkout +`bcd4d7b4bf9ac1ce91f9e25957d6599fda75f87d`, distinct from the source head. +Full Ubuntu suites: Python3.10 has6919passed/16skipped/24warnings; +Python3.12 has6918passed/17skipped/19warnings; Python3.13 and3.14 each have +6918passed/17skipped/10warnings. Each Ubuntu wheel job has3artifact tests passed +without skips. Each Windows/macOS wheel job has622passed/3skipped: two optional +MCP module omissions in the wheel extra, and the absent optional SVG renderer. +Actual HTTP/stdio MCP cases execute in the full all-extras Linux source jobs and +the isolated local SDK proof. All12 wheel jobs include installed optional/core +offline smoke; no skip is counted as a pass. + +The security job's two nonblocking commands exit1. pip-audit reports15 known +vulnerabilities in pip26.1.1, urllib3 2.7.0 and virtualenv21.3.3; all three exact +versions already occur in the imported upstream lock. Bandit reports4HIGH SHA1 +and8MEDIUM findings; the reported statements are present verbatim in the imported +upstream revision. No new production HIGH statement is identified. These are +recorded existing findings, not a clean security scan and not a Qt parser issue. +They remain upstream dependency/security debt rather than being silently fixed, +hidden or treated as a passing scanner result in this feature change. + +## INC-QML-08a native source compatibility + +Local Windows Python 3.12.14 checks verify the bounded native syntax correction +and same-package upgrade behavior. They do not close the full installed-project +adoption profile or supply new reviewed-head hosted OS/Python evidence. + +The focused command was: + +```powershell +.venv/Scripts/python.exe -X utf8 -m pytest -q tests/test_qt_cpp_adoption_syntax.py tests/test_qt_cpp_syntax.py tests/test_qt_signals_slots.py tests/test_qt_events_boundaries.py --tb=short +``` + +Result: **63 passed**, one pre-existing Hypothesis warning, 3.09 seconds. The public +fixtures cover empty-brace parameter defaults, evaluated `Q_UNUSED` arguments, +original BOM/CRLF/Unicode byte spans, valid numeric separators and later +signal/member ownership. Malformed, inert and ordinary-C++ controls retain their +rejection or canonical behavior. Before the numeric correction, four valid cases +failed while three malformed controls passed; all seven pass after correction. + +The upgrade/cache command was: + +```powershell +.venv/Scripts/python.exe -X utf8 -m pytest -q tests/test_qt_cpp_upgrade_invalidation.py tests/test_cache.py tests/test_qt_incremental_policy.py tests/test_qt_analysis_state.py tests/test_qt_worker_cache_integrity.py --tb=short +``` + +Result: **135 passed / 8 skipped**, 9.94 seconds. All thirteen new upgrade cases +pass. The skips require host symlink permission and are not passes. Actual +production cache and CLI boundaries cover schema 6 retirement, policy epoch 2 +refresh without source/package changes, accepted-input limits, plain-C++ warm +cache and byte-for-byte retention of prior cache/products after failure. + +The final broad run used `.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q` +with every file matching `tests/test_qml_*.py`, `tests/test_qt_*.py` and +`tests/test_cpp_*.py`, including the upgrade and numeric regressions. The equivalent +explicit PowerShell selection for reproducing that test set is: + +```powershell +$qtTestFiles = @(rg --files tests -g 'test_qml_*.py' -g 'test_qt_*.py' -g 'test_cpp_*.py') +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q @qtTestFiles +``` + +Result: **716 passed / 7 skipped / 1 warning**, 76.98 seconds. Four skips require +an unavailable C++ executable/toolchain, two require host symlink permission and +one requires the optional SVG renderer. The warning is a Starlette deprecation. +Ruff passes for the seven changed files; targeted Pyright using +`--pythonpath .venv/Scripts/python.exe` passes the three production files with zero +errors/warnings. The lock check passes with 210 packages. The navigation graph +refresh succeeds with 20012 nodes, 41589 edges and 1100 communities; those counts +describe the Graphify repository, not an analyzed private project. + +The clean reviewed installed wheel has SHA256 +`083b0d0bd422d91cd55b724088af2aa1301251bdeefb4a46d796c350d11f8a47`; +its reviewed production tree is `5aa323e403481f5e57c9caad77b4e244fed470a6`. +Installed source analysis and query/explain/affected consumers retain the bounded +native facts, and a no-change CLI update preserves the graph. This is local +artifact/scope evidence, not execution of analyzed Qt code, whole-project +compatibility or closure of typed provider/service relationships. + +Native-header classification remains a planned INC-QML-08a assessment: a C +dispatch can omit C++ facts despite a successful direct C++ parse. A bounded +classifier requirement, public positive/negative fixtures and production +installed-update proof are needed before admitting a correction. Remaining +qmake, provider/service, combined adoption and safety criteria remain open in +[traceability](../../tests/TRACEABILITY.md#planned-adoption-criteria). + +## INC-QML-09 HTML export and overview proof + +This section records the prior initial-selection revision: startup used the +ten-largest overview with Select All unchecked. Its test counts, source tree and +artifact digest remain evidence for that revision, not proof of current AC04. +Grouping/publication evidence for AC01–AC03 remains separately applicable. + +The executed broad selection comprises every `test_qml_*.py`, `test_qt_*.py`, +`test_cpp_*.py`, `test_html_*.py` and `test_export*.py` file plus test_cli_export.py. +The clean reviewed artifact is selected by GRAPHIFY_QML_TEST_WHEEL. + +```powershell +$testFiles = (Get-ChildItem tests/test_qml_*.py, tests/test_qt_*.py, tests/test_cpp_*.py, tests/test_html_*.py, tests/test_export*.py).FullName +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q @testFiles tests/test_cli_export.py --tb=short -rs +``` + +Result: **921 passed / 7 skipped / 1 warning**, 113.38 seconds. Four skips require +an unavailable C++ preprocessor, two require symlink permission and one requires +the optional SVG extra. The warning is the existing Starlette deprecation. +The focused recovery/export/CLI/Qt payload selection passed 149 with no skips; +initial-view/legacy/Qt written-script selection passed 83 with no skips. +The three initial recovery cases failed before their production correction. +The new Node harness executes emitted selection/search/filter/reset code while +isolating the external vis network; it is not a browser layout/appearance proof. + +Ruff passes the five changed code/test owners; targeted Pyright passes the grouping +owner and initial-view tests. The lock check resolves 210 packages. Required AST +navigation refresh succeeds with 20090 nodes, 41767 edges and 1041 communities. +Those are the Graphify repository's counts, not a private analyzed application. + +Reviewed production tree: `b43624a4b933ca012e9430d90772cb72c0bc14d3`. +Clean installed-wheel SHA256: +`7dc82ac66a2d50880e537901f5f26e7ab7077b49cf6e9e1422ab3288fc5debc2`. +The installed production CLI at that reviewed revision exports an accepted large +unclustered graph into complete labeled communities, without changing its JSON. +Its default datasets contain only the bounded overview; prior full source data +remains available. +Private artifacts are retained outside this public repository. Source/fixture, +CLI, artifact and emitted-script evidence are distinct from browser visual, +other platform or hosted PR validation, which is not claimed for this revision. + +## INC-QML-09 current selection policy + +Current REQ-QML-019-AC04 requires Select All checked at startup and every exported +view node/edge active before layout. Large source graphs retain complete labeled +community aggregation and its supported cap. Overview is an optional ten-largest +subset with Select All unchecked; filters, search and all/none controls remain. +No saved camera/filter persistence or canonical graph mutation is introduced. +This checked-default criterion is **locally verified at the exporter/CLI and +emitted-script boundaries**, including the reviewed installed artifact. The prior +921-pass run and wheel digest above remain historical rather than establishing +the changed startup behavior. + +The focused production command was: + +Historical invocation/source excerpt: [frozen VALIDATION lines 781–784](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/docs/qt-qml/VALIDATION.md#L781-L784). Deferred owners: [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py), [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py). + +Result: **157 passed**, no skips, 43.03 seconds. The new expectations first failed +with seven failed/one passed in 1.95 seconds; all eight initial-view cases pass +after correction within this selection. The emitted production scripts exercise +checked Select All, complete exported-view datasets before layout, optional +Overview and selection controls while isolating the external vis network. + +With `GRAPHIFY_QML_TEST_WHEEL` selecting the reviewed artifact, +`tests/test_qml_wheel_artifact.py` passes **three cases**, no skips, 4.85 seconds. +Clean `uv build` and installation succeed. All 152 packaged Python payloads match +the reviewed source and installed artifact byte for byte. The actual installed +export has checked startup/all exported-view data verified through its emitted +script harness, preserving canonical graph JSON. Ruff passes the two changed +files, and targeted Pyright reports zero errors/warnings for the initial-view +tests. + +Reviewed production tree: `bdbc86f2da6e199157c9504ee9fc5cdc86f3f186`. +Installed-wheel SHA256: +`bde373cac5c37657283df6cf2070c23d89703b77785e2d6f3041a75b0df9f964`. +The HTML owner remains 763 physical lines and the initial-view test owner is 234; +other recorded owner sizes are unchanged. Browser visual, other-platform and +hosted proof remain separate unexecuted evidence for this revision. This result +does not establish closure of separately investigated source or display cases. + +## INC-QML-10 native source ownership + +**Current result: 88 focused and 954 broad cases pass**, with seven documented +broad-suite skips. The final reviewed wheel, installed proof and accepted-context +contract appear below. Earlier ownership-only counts and artifacts are retained +as intermediate evidence. + +**Intermediate proof before accepted-context correction.** Existing criteria +REQ-QML-008-AC02, REQ-QML-016-AC01/AC04 and REQ-QML-017-AC02/AC04 have new public +native-ownership regressions. The 78/944 counts and tree/wheel below precede the +accepted-context body-key correction; they are not its final proof. Earlier profile, syntax and HTML counts/artifacts +above remain evidence for their recorded revisions. + +Production proof covers retained forward facts/IDs, unique complete-class +authority, accepted canonical header/implementation definition provenance and +member/emission/reverse-access ownership through graph build, serialization and +consumers. Negative cases include forward-only ownership, distinct complete-class +ambiguity, wrong file/line, conflicting qualification/signature and missing +canonical evidence. Genuine unsupported relationships remain unresolved; no +claim is made that every isolated Qt node should acquire an edge. + +Qt policy epoch 3 with AST cache schema 6 unchanged refreshes the same package +version's ownership facts. The new upgrade cases exercise actual unchanged-source +CLI refresh and preservation of prior graph/manifest/stamp after a rejected native +candidate. Before the epoch correction, two upgrade cases failed because +`inspect.changed` stayed false, with two deselected, in 1.08 seconds. The public +ownership tests also reproduced four failing cases before their correction. + +The focused command was: + +```powershell +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q tests/test_qt_cpp_owner_upgrade.py tests/test_qt_cpp_definition_ownership.py tests/test_qt_cpp_upgrade_invalidation.py tests/test_qt_analysis_state.py tests/test_qt_incremental_policy.py --tb=short -rs +``` + +Result: **78 passed**, 6.55 seconds in the intermediate focused run, including actual JSON +publication/reload assertions for namespace-scoped QML access. The new ownership module has 18 cases and the +owner-upgrade module has four. Source pairs, BOM/CRLF/Unicode spans, namespace +qualification, normalized paths, missing/foreign definition evidence, genuine +complete-class ambiguity, unsupported owners and unrelated plain C++ controls +exercise the actual extractor/facade/build/reload boundaries. + +The reviewed artifact broad command was: + +```powershell +$env:GRAPHIFY_QML_TEST_WHEEL = (Resolve-Path .venv/qml-10-wheels/graphifyy-0.9.74-py3-none-any.whl).Path +$testFiles = (Get-ChildItem tests/test_qml_*.py, tests/test_qt_*.py, tests/test_cpp_*.py, tests/test_html_*.py, tests/test_export*.py).FullName +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q @testFiles tests/test_cli_export.py --tb=short -rs +``` + +Result: **944 passed / 7 skipped / 1 warning**, 102.58 seconds. Four skips require +an unavailable C++ preprocessor, two require Windows symlink permission and one +requires optional SVG support. The warning is the existing Starlette deprecation. + +`uv build` produces `graphifyy-0.9.74-py3-none-any.whl`, installed in an isolated +environment without changing the package version. Intermediate reviewed production tree: +`3ee40bc9fa8b8c182664fa601ef2d5e68c2a078c`. Intermediate wheel SHA256: +`684b83764ec20cc2e24af0c0ae8fa3f9b9b02a5f38b8bbe02b3d25cc7ea13e32`. +All 152 Python payloads match reviewed source, wheel and installation with zero +mismatches. Ruff passes all seven changed production/test files. Explicit-runtime +Pyright passes with **zero errors and zero warnings**: + +```powershell +.venv/Scripts/pyright.exe --pythonpath .venv/qt-mcp-312/Scripts/python.exe graphify/extractors/qt_cpp_mapping.py graphify/extractors/qt_cpp_exposure.py graphify/qt_event_index.py graphify/qt_qml_bridge.py graphify/qt_incremental.py tests/test_qt_cpp_definition_ownership.py tests/test_qt_cpp_owner_upgrade.py +``` + +An initial default-environment invocation could not resolve pytest imports. +Selecting the configured runtime corrected that environment mismatch; source and +assertions are preserved. The final required `graphify update .` also exits zero +after the publication/reload assertions. Production source and installed artifact +are unchanged. Measured new test +owners are 261 and 91 physical lines; production +mapping/exposure/event-index/bridge/policy owners are 233/131/111/110/114. This is +local Windows exporter/source/CLI/artifact proof; no new hosted or other-platform +result or runtime Qt equivalence is claimed. + +### Accepted unchanged-context proof + +Borrowed complete-class records retain exact producer `source_file`/`span` +alongside ID/name/definition authority. Without those fields, a repeated accepted +body acquired a different deduplication key from its fresh AST representation and +falsely competed with itself. The correction preserves body identity, immutable +context inputs and canonical class/member/emission ownership while retaining +real distinct-definition ambiguity. + +Four positive context cases failed before correction, with three passed in +1.35 seconds under `.venv/Scripts/python.exe`; the warning was existing +Hypothesis behavior. The final context module has ten passing cases, including +three equivalent-path cases, direct collection and actual Qt pipeline/join/build/ +JSON reload, namespace/qualified ownership, and distinct-body/legacy/missing +controls. Its physical size is 145 lines; exposure is 134 lines. + +The current focused command was: + +```powershell +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q tests/test_qt_cpp_context_ownership.py tests/test_qt_cpp_owner_upgrade.py tests/test_qt_cpp_definition_ownership.py tests/test_qt_cpp_upgrade_invalidation.py tests/test_qt_analysis_state.py tests/test_qt_incremental_policy.py --tb=short -rs +``` + +Result: **88 passed**, 7.38 seconds. Ruff passes all eight changed production/test +owners; Pyright with the explicit configured Python runtime reports zero errors/ +warnings for the same eight owners. + +Final reviewed production tree: +`360b714402af8915c0b301f875c7a469733a02e0`. Rebuilt +`graphifyy-0.9.74-py3-none-any.whl` SHA256: +`b3266bb402ef1ef79287046d7c192df82c08ba3655a9497eb7b6dd9f5cc136d8`. +Clean `uv build` and isolated installation pass; all 152 Python payloads match +reviewed source, wheel and installation with zero mismatches. The installed +public-fixture CLI rejects damaged QML even with force/partial flags, preserving +prior published bytes; repair and repeated update succeed and restore the graph. +The first expanded broad run has **one failed / 953 passed / 7 skipped / 1 +warning**, 105.89 seconds. The existing +`tests/test_qml_skillgen_guidance.py::test_qt_marker_does_not_sanction_unrelated_monolith_edits` +negative-control case failed when `tools.skillgen.gen._git_show` received a +nonzero Git baseline-read result for `skill-devin.md`, with no stderr. The +recorded commit/blob is subsequently readable by `git cat-file` and `git show`. +An isolated rerun of the entire guidance module passes **24 cases**, 2.70 seconds, +with no source or assertion changes. The read failure is not reproduced there; +its exact cause is unknown. The repeated full broad run passes **954 cases / 7 +skipped / 1 existing warning**, 101.89 seconds, with no source or assertion edits +between the failed read and the confirmation run. Its command is: + +```powershell +$env:GRAPHIFY_QML_TEST_WHEEL = (Resolve-Path .venv/qml-10-final-wheels/graphifyy-0.9.74-py3-none-any.whl).Path +$testFiles = (Get-ChildItem tests/test_qml_*.py, tests/test_qt_*.py, tests/test_cpp_*.py, tests/test_html_*.py, tests/test_export*.py).FullName +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q @testFiles tests/test_cli_export.py --tb=short -rs +``` + +The same seven dependency/platform skips and Starlette warning are accounted for +above. Final Ruff and explicit-runtime Pyright include the new +`tests/test_qt_cpp_context_ownership.py` with the other seven owners; both pass. +`graphify update .` exits zero after all source/test changes. The final installed +source-only full/unchanged-repeat and HTML checks pass without changing the +accepted graph or view bytes established by the full-corpus ownership correction. +Browser/runtime, other-platform and new hosted proof remain unexecuted for this +revision. Policy 3 and AST schema 6 remain unchanged because the repaired body key +is internal record transport, not a new persisted fact contract. + +## INC-QML-11 inherited-signal lookup gap + +**Planned; not implemented or verified.** Recursive inheritance compatibility in +`QtEventIndex.inherits()` does not supply grandparent member candidates because +`member()` currently considers only immediate bases. Existing +REQ-QML-016-AC01/AC04 own the planned endpoint and persisted/incremental correction. +No new test command, artifact or successful criterion evidence is assigned to +this gap. [INC-QML-11](PLAN.md#inc-qml-11--inherited-qt-signal-endpoint-lookup) +records the bounded source/ambiguity controls and exit conditions. + +## INC-QML-12/13 current source and view evidence + +**Bounded source implementation, regression and reviewed installed-artifact +evidence.** INC-QML-12 corrects the generic constructor +producer/canonicalization boundary; INC-QML-13 preserves native source-site/file +containment and clarifies community edge counts. These retain existing +REQ-QML-008-AC02, REQ-QML-010-AC02/AC04, REQ-QML-011-AC03/AC04, +REQ-QML-012-AC02, REQ-QML-016-AC01/AC04, REQ-QML-017-AC02/AC04 and +REQ-QML-019-AC02 identities. Exact criterion assignments are in +[traceability](../../tests/TRACEABILITY.md#constructor-and-source-containment-corrections). +The separate inherited-signal gap above remains planned. + +### Source and rejection contracts + +Generic constructor declarations retain callable identities and byte spans. +Only a unique accepted complete class, matching source-backed declaration/ +definition/signature and extracted ownership proof establish the canonical +parent. Corrupted copied owner fields, conflicting parents, foreign scopes, +wrong source/line, non-callable nodes, malformed transport and invalid spans +cannot authorize it. Existing unrelated generic edges survive. BOM, CRLF and +Unicode fixtures compare spans with the original bytes. + +Native member occurrences and function-local classes may be contained by an +independently proven canonical callable without claiming native class authority +or QObject exposure. If no callable owner can be proven, the final helper may +contain the source occurrence by its uniquely accepted canonical AST file. +Fresh canonical file IDs are passed explicitly before final AST provenance +tagging; borrowed context requires its persisted AST marker. Missing, duplicate, +foreign/out-of-root, semantic, callable/class and unmarked borrowed file records +cannot create that link. Original occurrence metadata, empty `owner_id` and +unavailable native target remain unchanged. This is source containment, not +evidence of signal delivery or a QML runtime object. + +Supported ordinary/parameterized constructors have actual facade/build/JSON +reload, query and affected proof. Manual update/watch tests cover parameter +edits, emission removal, cold/warm/full parity, prior-product retention after +malformed-source rejection, corrected retry and no-change repeat. Duplicate +complete classes, foreign namespaces and collapsed overloaded delegation remain +unproved. In the unsupported overloaded fixture, generic untyped containment +already differs in source location between cold build and watch; typed Qt facts +and nodes agree. That existing exact-overload identity gap is retained separately +and does not weaken full normalized parity for supported constructors/members. + +Six new modules exercise these production boundaries: + +| Module | Evidence scope | +| --- | --- | +| `tests/test_cpp_constructor_ownership.py` | Direct generic producer/facade ownership, identity/signature, byte spans and corrupt/foreign/conflicting-proof rejection | +| `tests/test_qt_constructor_ownership.py` | Native emission and literal QML access ownership through actual assembly/publication/reload/query/affected, edit/removal and manual/watch failure/retry | +| `tests/test_qt_member_source_links.py` | Exact accepted callable containment without type authority, local source occurrences, immutable borrowed context, negative proof controls and actual JSON/HTML | +| `tests/test_qt_source_file_containment.py` | Real facade/source-file links for unresolved sites, accepted AST role controls, reload, refresh and stale-site removal | +| `tests/test_qt_source_links_upgrade.py` | Same-version CLI upgrade from policy/schema 3/6 and 4/7, unchanged-source reparse, unrelated Python, no-change parity and durable failure retention | +| [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) | Production emitted inspector script and aggregate payload counts, escaping, immutable source graphs and supplied-meta/small-view controls | + +### Red/green and compatibility evidence + +The source-site suite initially had **7 failed / 12 passed** before its ownership +correction. A later actual facade source-file test had **1 failed / 54 passed** +because final AST origin tagging occurs after the Qt pipeline. Passing explicit +fresh AST IDs corrected that ordering; the current focused containment selection +has **63 passed**, 9.56 seconds. This earlier command preceded the last +source-file lifecycle/upgrade parameter additions: + +```powershell +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q tests/test_qt_source_file_containment.py tests/test_qt_constructor_ownership.py tests/test_qt_member_source_links.py tests/test_qt_cpp_definition_ownership.py tests/test_qt_source_links_upgrade.py --tb=short -rs +``` + +Final six-module source proof passes **90 cases**, 14.70 seconds, with no skips: + +Historical invocation/source excerpt: [frozen VALIDATION lines 1024–1027](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/docs/qt-qml/VALIDATION.md#L1024-L1027). Deferred owners: [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py). + +An earlier 88-pass/two-failure execution of that selection in 12.95 seconds +exposed the unsupported generic overload source-location parity discrepancy. +A public comparison without the new augmentation reproduced the same baseline +discrepancy. The documented overload case now compares every node and typed Qt +edge; supported singleton constructor tests retain full graph parity. This gap +is planned under INC-QML-15, not recorded as verified exact-overload behavior. +Final source-file/upgrade proof passes **20 cases**, 6.06 seconds: + +```powershell +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q tests/test_qt_source_file_containment.py tests/test_qt_source_links_upgrade.py --tb=short -rs +``` + +No aggregate constructor red count was captured. Its retained regression +assertions cover the original missing prototype/implicit-parent failure and the +adjacent false-owner/corrupt-proof controls; the source-site and file-site red +counts above must not be attributed to constructor-only tests. + +The HTML count regression first had **6 failed / 1 passed**, 2.72 seconds, before +the exporter/inspector change. The completed new module has **11 passed**, 1.96 +seconds. The emitted production script runs in Node with the external DOM/vis +boundary isolated; this is executable payload/selection/inspector evidence, not +a new browser-engine visual acceptance run. The related production command was: + +```powershell +$htmlTests = (Get-ChildItem tests/test_html_*.py).FullName +.venv/Scripts/python.exe -X utf8 -m pytest -q @htmlTests tests/test_export.py tests/test_cli_export.py tests/test_qt_graph_html_payload.py tests/test_qt_html_consumers.py --tb=short -rs +``` + +Result: **172 passed**, 42.36 seconds, with the existing Hypothesis collection +warning. Ruff passes the three viewer/test owners; explicit-runtime Pyright +reports zero errors/warnings for the viewer and new test module. Counts use +canonical NetworkX source edges, including represented directed/parallel edges +and self-loops. Internal edges do not become plotted aggregate loops. Distinct +neighboring communities, external source edges and internal source edges remain +different quantities. Pre-aggregated views lacking accepted source counts report +them unavailable; full small views retain ordinary Degree. Escaping, Select All, +display bounds and source graph/group/label immutability remain covered. + +### Earlier reviewed artifact + +An intermediate policy-4/schema-7 artifact passed **1029 cases / 7 skipped** in +132.39 seconds. Reviewed production tree: +`7ef6033868e56ae0bbddb170583826db48b20f05`. Wheel SHA256: +`206aaf4b7adb5660f1de78e0fb9aa6448c03367961ae97a90cce718977995b0a`. +All 153 Python payloads matched reviewed source, wheel and isolated installation. +Those seven skips retain the existing four unavailable C++ preprocessor, two +Windows symlink-permission and one optional SVG limits. This earlier artifact +does not contain the final source-file proof and does not verify policy 5. + +### Final reviewed-revision gates + +Current source policy is **Qt analysis 5 / AST cache schema 7** at the unchanged +package version. Old analysis policy invalidates derived source links; incompatible +AST schema retires stale generic constructor output. Real upgrade tests use the +production CLI and reader/cache path rather than replacing analysis behavior. +Current failure guards preserve earlier graph/manifest/stamp/root-marker bytes; +the new source link does not introduce a persistence writer. + +The final broad command is: + +```powershell +$env:GRAPHIFY_QML_TEST_WHEEL = (Resolve-Path .venv/qml-13-final-wheels/graphifyy-0.9.74-py3-none-any.whl).Path +$testFiles = (Get-ChildItem tests/test_qml_*.py, tests/test_qt_*.py, tests/test_cpp_*.py, tests/test_html_*.py, tests/test_export*.py).FullName +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q @testFiles tests/test_cli_export.py --tb=short -rs +``` + +Result: **1044 passed / 7 skipped / 1 existing Starlette warning**, 137.43 +seconds. The seven skips are the same four unavailable C++ preprocessor, two +Windows symlink-permission and one optional SVG boundaries; none is a passing +acceptance lane. The final artifact remains `graphifyy-0.9.74-py3-none-any.whl`. + +| Final gate | Evidence state | +| --- | --- | +| Complete current source/test selection | 1044 passed / 7 skipped / 1 existing warning, 137.43 seconds; exact command above | +| Reviewed production tree identity | `0c65c34737aea316dcd879efb8e6d1a0ba6d9c0b` | +| Final policy-5/schema-7 wheel SHA256 | `04826d98f11c70edcfd694af169ba54624df107a2c10c07dbdc3cb9092821f95` | +| Source/wheel/isolated-install Python payload equality | All 154 Python modules byte-equal; zero mismatches | +| Installed public source-file/constructor/update/retention proof | Final isolated wheel CLI passes; malformed source with force/partial options exits 1 and retains four products; repair and repeat exit 0; no Qt source facts are isolated | +| Final Ruff and explicit-runtime Pyright | Commands below pass: Ruff clean; Pyright zero errors, warnings or information diagnostics | +| Traceability/validation document checks | 172 exact automated test references and 31 local file/fragment links resolve within these two documents; final review across all 11 modified public documents resolves 199 exact test references and all local file links; all 81 criteria assigned; public-pattern and diff checks pass | +| Required `graphify update .` | Final required invocation exits 0 | + +The isolated installed interpreter executes the integration owner's offline +`verify-source-links-final-wheel.py` production proof. Its public fixture publishes +graph/manifest/analysis/root-marker products, then damages source, invokes the real +CLI with force/partial options, verifies rejection/byte retention, repairs source +and repeats the update. The final public graph SHA256 is +`028adf8e6fb4377ef3501d9c5c3686c6c72b90e5d487f63b14ca168c619b30fc`. +This installed fixture uses the final wheel, policy 5 and schema 7 rather than +the earlier 1029-case artifact. + +An additional installed adoption refresh and default HTML export pass. The +resulting graph has no isolated Qt source facts; an unchanged repeat exits 0 and +leaves prior outputs untouched. The generated production viewer's actual script +harness confirms Select All and truthful internal-only community counters. +No private source, project identifier or machine path is retained here; no new +browser visual claim follows from that harness. This source-analysis check does +not complete REQ-QML-018's separate metadata/provider/whole-profile matrix. + +Final lint/type commands cover the focused production owners and six new suites: + +Historical invocation/source excerpt: [frozen VALIDATION lines 1129–1137](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/docs/qt-qml/VALIDATION.md#L1129-L1137). Deferred owners: [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py). + +The lock check resolves 210 packages with no lockfile change. These scoped type +checks do not claim that the oversized legacy facade/extractor/resolver files +are free of their existing baseline type diagnostics. Additional cross-language, +cross-repository, C++ method and Objective-C guard compatibility passes 25 cases: + +```powershell +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q tests/test_cross_language_call_resolution.py tests/test_cross_repo_external_call_guards.py tests/test_cpp_method_declarations.py tests/test_cpp_objc_cross_file_calls.py --tb=short -rs +``` + +INC-QML-12/13 are locally complete for their recorded bounded source, artifact and +emitted-script contracts. No new hosted, other-platform, browser-layout or executable Qt proof is claimed. Broader +adoption provider gaps and INC-QML-11/15 remain explicitly unverified. INC-QML-14 +was unverified at this policy-5 checkpoint; its separate policy-6 proof follows. + + + +## INC-QML-14 membership projection + +REQ-QML-020-AC01–AC03 are **Locally Verified for the bounded static public +profile**. Earlier accepted +metadata/source/resource facts could resolve module and loader lookups in the +per-run index while only their owning metadata file contained them in the graph. +The new increment targets the missing graph route to declared, accepted source +files/components. Earlier truthful community counts and source-file containment +remain evidence for their existing contracts, not this new projection. + +Each independent source-owned `membership_resolution` site retains the raw +declaration's identity, source/span, module/alias context, evidence and status. +Declaration containment uses `qt_membership_site`; a resolved site references its +actual accepted canonical endpoint with confidence `EXTRACTED` and context +`qt_project_source` or `qt_resource_membership`. Missing, competing, conditional, +generated and unsafe targets retain coverage with no target edge. Raw producer +facts, borrowed dictionaries and existing loader/module results remain unchanged. +Static package membership does not establish runtime import, instantiated QML, +architectural dependency or an evaluated build branch. + +### Pre-fix production regression + +The original `tests/test_qt_project_membership_updates.py` lifecycle selection +has **4 failed**, 3.76 seconds before membership projection. Both actual manual +update and watch variants have no membership sites in their initial published +public fixture. The test reaches the real extraction/assembly/persistence path; +it does not replace the resolver or CLI behavior. This establishes the missing +membership regression, not success of later mutation or failure-retention steps. +Only safe scenario/count conclusions from the local red log are retained here. + +The actual facade positive CMake/qmake selection also has **2 failed**, 2.42 +seconds when only membership projection is disabled and real parsing/index +behavior remains. Its sites are absent despite the accepted source declarations. +An earlier import failure while the new module was not yet present is not used +as product regression proof. + +### Executed source tests and commands + +The two new public modules are +`tests/test_qt_project_membership.py` and +`tests/test_qt_project_membership_updates.py`. The exact criterion-to-test map is +in [traceability](../../tests/TRACEABILITY.md#project-membership-projection). +Their executed assignment covers the following production boundaries. Earlier +development checkpoints remain tied to their own source/test state. + +The integration owner's development lifecycle checkpoint reports **9 passed**, +8.57 seconds, including force rejection, fail-after-real-join retention, policy +refresh and aggregate inspection. The projection module's negative controls +still have failures under correction at that checkpoint. This partial selection +is not final reviewed-revision evidence or verification of any complete criterion. + +A later development focused selection has **36 passed**, 8.74 seconds. The +development broad selection has **1071 passed / 10 skipped / 1 existing Starlette +warning**, 130.98 seconds. Three skips are built-wheel tests because +`GRAPHIFY_QML_TEST_WHEEL` was unset; the other seven are two Windows symlink, +one optional SVG and four unavailable C++ preprocessor cases. These are source +development results, not reviewed-wheel proof. The final typed-file punctuation/ +published-context fixture follows that checkpoint. Investigation shows actual publication retains the raw punctuation +label, so an earlier escaped-label hypothesis is disproved; no production guard +is relaxed to accommodate that hypothesis. + +The pre-direction-expansion source-projection checkpoint has **26 passed**, +1.90 seconds: + +```powershell +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q tests/test_qt_project_membership.py --tb=short +``` + +The helper and test file at that checkpoint are 187 and 276 physical lines. Ruff passes those +two owners; explicit-runtime Pyright reports zero errors and warnings. This is +source-projection proof, not the final combined lifecycle/artifact gate. + +| Criterion | Executed source/lifecycle scope | Final evidence | +| --- | --- | --- | +| REQ-QML-020-AC01 | Public CMake/qmake and qrc fixtures, uniquely accepted file/component endpoints, independent/repeated declarations and same-name scoped paths, original BOM/CRLF/Unicode spans and punctuation, actual build/JSON reload/scoped query, unchanged module/loader lookup and serialized aggregate export/emitted inspector counts | Source/consumer and final reviewed installed public-fixture checks pass | +| REQ-QML-020-AC02 | Missing/duplicate/conditional/generated/unsafe/wrong-role and ambiguous alias controls, malformed literal transport/exact-location rejection, immutable borrowed facts and guarded target reads/discovery/process execution; real malformed-qrc manual/force/watch publication rejection with byte retention, repair and repeat; the actual projection helper executes before an injected completion failure | Explicit status/reason, source rejection and final installed retention/recovery checks pass | +| REQ-QML-020-AC03 | Cold/warm/full/manual/watch comparison, source edit/rename with updated metadata/alias, resource alias rename/duplicates/removal, target deletion, stale-edge retirement, stable unrelated Python and relocated-root identities, fresh-site replacement/publication without borrowed mutation, accepted typed-file identity through punctuation/publication, same-version policy-5 to policy-6 refresh and no-change repeat | Complete source/lifecycle/consumer, final broad compatibility and installed public-fixture checks pass | + +The reviewed-wheel focused checkpoint below passes **37 cases**, 8.77 seconds, +with no skips (26 projection cases and 11 lifecycle/consumer cases). Two further +directed graph variants expand CMake/qmake positives to directed and undirected +actual assembly/JSON reload, checking the same logical endpoints and original +declaration spans. The identical focused command then passes **39 cases**, +9.50 seconds, with no skips (28 projection and 11 lifecycle/consumer cases): + +The first two directed variants assumed JSON would retain redundant `_src`/`_tgt` +hints, which the established directed serializer omits because edge orientation +is structural. That incorrect test expectation is not product red proof. The +unchanged undirected assertions retain the hints; directed assertions prove the +actual site-to-target edge and absence of its reverse. No production behavior or +acceptance direction contract is relaxed. + +```powershell +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q tests/test_qt_project_membership.py tests/test_qt_project_membership_updates.py --tb=short -rs +``` + +The complete reviewed-wheel command passes **1081 cases / 7 skipped / 1 existing +Starlette warning**, 135.53 seconds: + +```powershell +$env:GRAPHIFY_QML_TEST_WHEEL = (Resolve-Path .venv/qml-14-wheels/graphifyy-0.9.74-py3-none-any.whl).Path +$testFiles = (Get-ChildItem tests/test_qml_*.py, tests/test_qt_*.py, tests/test_cpp_*.py, tests/test_html_*.py, tests/test_export*.py).FullName +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q @testFiles tests/test_cli_export.py --tb=short -rs +``` + +All three built-artifact tests execute with the reviewed wheel supplied. The +seven remaining skips are two Windows symlink-permission, one optional SVG and +four unavailable C++ preprocessor cases; no artifact test is skipped. These +results do not turn skipped lanes into verification. That broad execution +includes the earlier 37-case new membership selection. The two extra directed +cases have the separate final focused result above; no 1083-case broad run is +claimed. No production source or reviewed wheel changes follow this test-only +expansion. Current new helper/source-test/lifecycle-test sizes are 187/283/267 +physical lines, within the 300-line ceiling. + +### Current revision and exit evidence + +The correction uses **Qt policy 6 / AST schema 7 / package version 0.9.74**. +Actual CLI extract/update tests show the policy bump refreshes earlier same-version +graphs even with unchanged source bytes; the AST schema and +graph/manifest/checkpoint writer remain unchanged. +The earlier policy-5/schema-7 artifact and its 1044-case result above stay tied to +that earlier revision and do not verify the membership correction. + +| Gate | Current evidence state | +| --- | --- | +| Three individual criteria and two new source/lifecycle modules | Final 39 focused cases pass in 9.50 seconds; earlier wheel checkpoint has 37 passed in 8.77 seconds; original red lifecycle/facade failures retained | +| Related metadata/loader/native and unrelated-language compatibility | Reviewed-wheel broad selection: 1081 passed / 7 documented skips / 1 existing warning, 135.53 seconds | +| Same-version policy-5 to policy-6 refresh | Actual CLI extract/update tests pass with previous policy-5 state, unchanged source/package bytes, clean-rebuild equality and no-change repeat | +| Actual join/transport/publication failure, force rejection and recovery | Source manual/force/watch and fail-after-real-join tests pass; installed force/partial rejection retains four products and repair/repeat succeed | +| Final production tree | `a8656510281354913dcc92a3f733b70183874fbd` | +| Final wheel SHA256 | `221c7efa8db69e6cad048aea67d88fe3049cf98bae933816017aba833525dc6c` | +| Reviewed source/wheel/isolated installation equality | All 155 Python modules byte-equal; zero mismatches | +| Installed unused-component route and query/export/HTML | Public CMake/qmake/qrc fixture: 30 nodes, 31 edges, six resolved membership sites; query/explain/affected/HTML exit 0 and HTML leaves the graph unchanged | +| Final lint/type and lock | Five owners pass Ruff; explicit-runtime Pyright has zero errors/warnings in 2.29 seconds; lock check resolves 210 packages with no lockfile change | +| Required `graphify update .` | Exits 0; existing optional parser fixture warnings and Luau partial-fixture baseline remain explicit | +| Additional installed accepted-code-scope adoption | Refresh, HTML, query/explain/affected and no-change repeat exit 0 with the final wheel; no private source or identifiers retained and no whole-root/runtime claim | + +The final five-owner commands are: + +```powershell +$membershipFiles = @('graphify/qt_project_membership.py', 'graphify/qt_qml_pipeline.py', 'graphify/qt_incremental.py', 'tests/test_qt_project_membership.py', 'tests/test_qt_project_membership_updates.py') +.venv/Scripts/ruff.exe check @membershipFiles +.venv/Scripts/pyright.exe --pythonpath .venv/qt-mcp-312/Scripts/python.exe @membershipFiles +uv lock --check --offline +``` + +### Reviewed installed public procedure + +The integration owner runs the ignored public-fixture script +`verify-membership-wheel.py` with the isolated noneditable installation's Python: + +```powershell +& $installedPython -I -X utf8 $membershipProofScript +``` + +The two variables identify the provisioned isolated interpreter and that script; +machine-specific paths are intentionally omitted. Inputs are public static +CMake/qmake/qrc/QML/C++ fixtures. The script invokes the production CLI instead +of copying an accepted graph into place. Its six resolved sites are five project +source memberships and one resource membership. Query, explain, affected and +HTML commands exit 0, with no project execution or canonical graph rewrite. +It then damages qrc source and invokes the actual CLI with `--force` and +`--allow-partial`: exit 1 preserves graph, manifest, analysis stamp and root +marker byte-for-byte. Byte repair and repeat exit 0. The accepted public graph +SHA256 is `d9ce1b82fec2b092446416a5dc90c8de46de398b8087db23acc356e94bd24e62`. +That evidence is bound to the tree/wheel/payload identities above, Qt policy 6, +AST schema 7 and unchanged package version 0.9.74. + +An additional redacted installed adoption check of the accepted code scope passes +refresh, HTML, query, explain, affected and no-change repeat, each with exit 0. +This is actual final-wheel source analysis, not an inference from the synthetic +fixture. It does not establish whole-root discovery, unresolved provider/parser +forms, application execution or browser appearance. Unrelated C#/cross-language +compatibility also passes **97 cases**, 4.05 seconds: + +```powershell +.venv/qt-mcp-312/Scripts/python.exe -X utf8 -m pytest -q tests/test_cross_language_call_resolution.py tests/test_csharp_type_resolution.py tests/test_csharp_member_nodes.py tests/test_csharp_member_calls.py --tb=short -rs +``` + +No new hosted, other-platform, browser visual or executable Qt proof is claimed. +The [plan](PLAN.md#inc-qml-14--explicit-project-membership-relationships) owns +implementation and exit. Existing requirement/criterion identities, prior verified +revision evidence and broader adoption/inheritance/overload limitations remain +unchanged. The bounded public membership criteria are locally verified; the +additional accepted-code-scope adoption check passes, while REQ-QML-018's +whole-root/provider/parser and combined profile matrix remains unverified. + +## INC-QML-16 middle mouse navigation and Overview removal + +Status: **Locally complete for the emitted/installed HTML consumer profile**. +Native browser/device/platform interaction remains unverified. Acceptance: revised +REQ-QML-019-AC04 and REQ-QML-021-AC01–AC03. This consumer change does not invalidate +accepted source memberships or change Qt policy 6, AST schema 7 or package version +0.9.74. Previous Overview checks describe their original revision and do not +verify removal of that feature. + +Red characterization runs actual exporter output through Node.js with external +DOM/vis APIs isolated. Before production changes, the combined selection/navigation +run reports **30 failed / 4 passed**, 4.42 seconds: 28 cases lack camera movement, +and two expose the retained Overview control and a falsely checked Select All +when ungrouped nodes remain hidden. The four retained startup/filter/search cases +pass. These failures establish the interaction and paired-selection regression; +the harness does not implement the production coordinate calculation. + +The initial green selection/navigation run passes **34 cases**, 4.02 seconds. +Review requires actual community projection as well as source rendering and +additional unavailable-capture/invalid-input boundaries before final closure. +The first wider exporter run reports **3 failed / 168 passed**, 49.08 seconds. +Those three inspector tests model `window` as an empty object, lacking the real +browser event-registration API now required by emitted navigation. Extend that +external boundary while retaining the original inspector assertions, then rerun +the complete affected exporter selection. Do not hide those failures or claim +that an incomplete harness establishes browser compatibility. + +Final commands and outcomes below bind the frozen source/test owners. The local artifact procedure builds the reviewed tree, +checks wheel contents against source and the isolated installation, regenerates +HTML with the installed CLI and proves unchanged canonical graph bytes. Actual +emitted-script movement/cleanup, default selection and source/aggregate inspectors +supplement that artifact identity check. No new browser visual, other-platform, +hosted or executable Qt proof is implied. + +### Final local and artifact evidence + +The final review restores retained source metadata, public Qt projection, initial +edge counts and endpoint-safety assertions independently of the removed Overview +expectations. No retained product assertion is weakened for this removal. + +| Boundary | Executed result | +| --- | --- | +| Final focused controls/navigation | 60 passed, 6.57 seconds; 24 pan cases cover independent horizontal/vertical/diagonal movement, four scales and actual source/community exporters | +| Final exporter/CLI/membership/inspector regressions | 231 passed, 53.15 seconds; no failures or skips | +| Additional Qt HTML consumers and reviewed wheel | 7 passed, 4.60 seconds; no failures or skips; 238 distinct final selected cases across both runs | +| Inspector harness correction | Three former failures pass, 1.55 seconds; full legacy export file passes 67 cases, 2.95 seconds; assertions unchanged | +| Reviewed production tree | `a991dd9fb2d638cf15fbb2c76499ed1f6ebd51ea` | +| Wheel SHA256 | `11e370a19d328a01b4e9e30726731c815b4ef58399833ff3d0a7c644f71e98f0` | +| Reviewed source/wheel/isolated tool | All 156 Python modules byte-equal; zero mismatches | +| Installed CLI HTML export | Exit 0; Overview control/ranking/reset absent; initial Select All complete; actual emitted script camera moves, release/blur cleanup and retry pass; source inspectors retained | +| Source retention | Prior accepted canonical graph bytes and RAW nodes/edges/legend are identical after installed HTML regeneration; no re-extraction needed | +| File ceilings | HTML 789/810; helper 116/300; initial-view tests 245/300; middle-pan tests 223/300; inspector compatibility owner 1377/1377 | +| Ruff and type checks | Five touched owners pass Ruff. The four navigation/source/test owners pass explicit-runtime Pyright with zero errors/warnings. Legacy export tests retain one proven pre-existing error described below | +| Lockfile | Offline check resolves 210 packages; no dependency or lockfile change | + +Historical invocation/source excerpt: [frozen VALIDATION lines 1397–1408](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/docs/qt-qml/VALIDATION.md#L1397-L1408). Deferred owners: [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py), [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py), [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py), [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py), [frozen html_navigation owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/exporters/html_navigation.py). + +The wheel placeholder identifies the digest above rather than an arbitrary +same-version artifact. Package version 0.9.74, Qt policy 6 and AST schema 7 remain +unchanged. The final production subtree is compared with the reviewed tree before +handoff; documentation/test commits do not substitute for that byte binding. + +Including `tests/test_export.py` in Pyright reports one existing +`reportOptionalOperand` at line 1008: the backup test uses an optional return as +a path without narrowing. The exact pre-change `HEAD` file reproduces the same +error under an explicit configuration that includes the ignored baseline copy. +The two-line growth at the inspector window API, line 1307, does not affect that +test. The baseline error is retained and disclosed; no assertion, type rule or +unrelated backup behavior is changed. + +The required repository graph update exits 0 after source changes. Six existing +optional-parser fixture warnings and the deliberate Luau partial fixture remain +explicit; no model labeling or corpus execution runs. A final update after the +retained test assertions are restored keeps the local graph current. +The increment review adds no further scope. Broader adoption, inherited endpoints +and generic overload parity remain with INC-QML-08/11/15, and native browser/device, +other-platform and hosted proof remain unverified system gaps. + +## INC-QML-17 receiver ownership and construction trees + +The original A10/A11 production probes reproduce five failures and one retained +positive before correction. Ordinary collected regressions now cover receiving +object members, accepted source/native inheritance, readonly rejection, +recursive/direct-only QObject child lookup, duplicates, unknown/template types, +parenting changes, corrupt construction proof and unproved template casts. +The supported findChild type filter is exact `QObject*`; other casts remain +explicitly unsupported. QML lexical expression lookup remains unchanged. + +The new native negative matrix also exposed a bounds-only native module namespace: +a rejected gadget supplied version bounds before provider admission, causing a +join exception. Bounds now belong only to accepted QObject providers. The plain +gadget regression passes without weakening its no-target assertion. The existing +reverse-update fixture now explicitly imports QtQml for its property-held QtObject; +all original member, direction, removal and parity assertions remain intact. + +| Executed boundary | Result | +| --- | --- | +| Ordinary receiver/access/lifecycle source selection | 58 passed, 11.72 seconds; one existing Hypothesis warning | +| Reviewed wheel, separately installed host package and reused pinned test dependencies | 98 passed, 19.03 seconds; no skips or failures | +| Wheel SHA256 | `d2c6d9ac3d17e66cdc750af46ab138862846266bb77ecdb3aa2667087e18b363` | +| Reviewed source/wheel/installed Python payload | 156 modules byte-equal, zero mismatches | +| Real manual/watch lifecycle | Eight cases cover cold/warm parity, member/tree edits, stale-edge removal, policy upgrade, parse/join/actual replacement failure retention, repair and no-change repeat | +| Focused Ruff / explicit-runtime Pyright | Passing; zero type errors/warnings | + +Artifact verification imports the separately installed wheel before pytest can +prepend the reviewed test tree, then exercises real extract/build/export/CLI/watch +and directed affected loading. It reuses the pinned test/parser dependencies; +it is not a new core-only, clean-extra, other-platform or hosted installation lane. +Policy 7 forces unchanged Qt inputs to refresh; AST schema 7 is unchanged. +The index/declaration/test owners measure 194/157/224 lines, respectively, under +their 300-line ceilings. Full current contribution gates are executed at the +INC-QML-20 integration boundary. Final aggregate evidence does not substitute for +the exact acceptance mappings in traceability. + +The plan review retains INC-QML-08/11/15 and proceeds to INC-QML-18. No additional +increment is required by the bounded receiver correction. Dynamic Qt runtime +parenting, arbitrary casts and unavailable platform/system proof remain explicit. + +## INC-QML-18 declaration identities and provider lifetime + +The original disjoint-engine probe failed three exposure cases before the +correction. Ordinary collected source tests now cover exact local/parameter/auto +declarations, disjoint/nested shadows, conditional/deferred uses, writes, duplicate +declarations, expired providers, precise/mixed rejection diagnostics and original +BOM/Unicode/CRLF spans. Existing QML/native connection controls remain passing. + +| Executed boundary | Result | +| --- | --- | +| Engine/provider/access source selection | 47 passed, 12.40 seconds; no skips | +| Reviewed wheel and separately installed host package, pinned test dependencies | 105 passed, 28.73 seconds; no skips or failures | +| Wheel SHA256 | `ed3e4b28a0bec54f95de455b7e5d88a8c2038c6974ec3e6ebfcb0acb816c74e7` | +| Reviewed source/wheel/installed Python payload | 157 modules byte-equal; zero mismatches | +| Lifecycle | Real manual/watch engine/load/provider edits and metadata/QML-only refresh remove stale bindings; forced malformed analysis preserves four products and repair/repeat succeeds; directed reload/query/explain/affected retains endpoints | +| Focused Ruff / explicit-runtime Pyright | Passing; zero type errors/warnings | + +The reviewed artifact contains INC-QML-18 only, while disjoint native-alias work +remains unstaged. It imports the separately installed package before pytest adds +the reviewed test sources. The package lane reuses pinned test/parser dependencies; +no new clean-extra, native Qt, other-platform or hosted result is claimed. Policy +8 refreshes prior derived overlays; AST schema 7 remains unchanged. Full current +contribution gates run at INC-QML-20. Query rendering asserts public labels and +locations; exact persisted/affected endpoint IDs remain independently checked. + +Plan review keeps factory/member provider admission, inherited endpoints and +overloads in INC-QML-08/11/15. The observed widget construction counterexample is +assigned to INC-QML-19's canonical-base integration under the existing receiver +acceptance IDs. No new increment is needed for these bounded scope corrections. + +## INC-QML-19 lexical aliases and native construction authority + +Ordinary collected tests promote A13, accepted-header shadows, comma-separated +typedefs and source-local using/namespace aliases. Header targets remain +unavailable; no preprocessing or SDK discovery is claimed. Widget/unknown-base +construction, corrupt native proof and shadowed QObject filters keep no target. + +| Executed boundary | Result | +| --- | --- | +| Native aliases, lifecycle, existing events/registration/provider selection | 84 passed, 19.60 seconds; no skips | +| Native construction, receiver and real lifecycle selection | 88 passed, 20.09 seconds; one existing Hypothesis warning | +| QObject filter shadow RED → GREEN | Prior installed INC-QML-18 package: three false persisted targets, two controls pass; current source: five pass | +| Reviewed wheel and separately installed host, pinned test dependencies | 185 passed, 43.86 seconds; no skips or failures | +| Wheel SHA256 | `aea6cdad6126dd15e7552a5afb6800134a40b556b134f1e2df99e7912d6614a0` | +| Reviewed source/wheel/installed Python payload | 159 modules byte-equal; zero mismatches | +| Focused Ruff / explicit-runtime Pyright | Passing; zero errors/warnings | + +The reviewed staged artifact excludes pending INC-QML-20 loader changes. Package +proof imports the installed wheel before test sources; pinned test dependencies +are reused, with no new clean-extra/native/other-platform/hosted result. Policy 9 +refreshes old Qt overlays; AST schema 7 remains compatible. Current full +contribution gates follow at INC-QML-20. + +Native class proof exposed an independent generic producer collision: global Base +and Public::Base in one file share a canonical ID. An ordinary negative control +retains ambiguity; separate-file qualified controls pass. INC-QML-21 plans the +canonical producer correction rather than inventing downstream IDs. Native +construction rejection does not claim the colliding form works. Wider adoption, +inherited endpoints and overloads remain in INC-QML-08/11/15. + +## INC-QML-20 literal loader correction and contribution gates + +Source review includes direct literal engine URL construction, component loadUrl/ +create, exact component-owned context engine identity and bounded SDK URL/mode/ +creation authority. Ordinary cases supersede the original loader audit probe. +Final rejection tests exposed ten further false-positive wrapper/mode cases and +three unsupported explicit-global controls before correction; all thirteen then +pass. Source-defined wrappers are not evaluated to inspect their return value. + +| Executed boundary | Result | +| --- | --- | +| Final loader/provider/lifecycle/reverse-access/source-state/language selection | 99 passed, 21.70 seconds, no skips | +| Final overload/URL/mode wrapper module | 49 passed, 2.89 seconds | +| Reviewed staged wheel and separately installed host | 370 passed, 67.37 seconds; no skips/failures | +| Wheel SHA256 | `bdd78e676c3311af15c348be8e4db1658651d0a9969a97ff9794b5de412e854d` | +| Reviewed source/wheel/installed Python payload | 160 modules byte-equal; zero mismatches | +| Full contribution command `uv run --no-sync pytest tests -q --tb=short` | 72 failed, 7,148 passed, 177 skipped, 3 warnings; 572.27 seconds | +| UTF-8 module-entry production recovery selection | 29 passed, 2 skipped, 1 existing warning; 7.77 seconds | +| Remaining exact 60 failure cases, current / original `1128205` baseline | 60 failed / 60 failed, identical failing case sets; 6.33 / 6.81 seconds | +| Full Ruff, reviewed source | Passing | +| Full Pyright canonical command, original comparison | 647 errors/4 warnings on baseline and correction source; all 651 diagnostics shared, zero added/removed | +| Focused changed-file Pyright | Passing, zero errors/warnings | +| Generated assistant contracts | 134 artifacts match; coverage, schema singleton and both round trips pass | +| Lockfile `uv lock --check --offline` | Passing, 210 packages | + +The installed-wheel selection imports installed production before staged tests; +it reuses pinned test dependencies and does not establish clean-extra, native Qt, +other-platform or hosted proof. An initial artifact invocation referenced a +nonexistent parser-test filename and collected no tests; the corrected command +uses the actual parser-probe module. Its first isolated child lacked tree-sitter; +the reviewed host then installed the pinned offline parser dependencies. The +terminal 370-case run is acceptance; the environment failure remains recorded. +Package source is frozen before later follow-up development, so the wheel does +not include INC-QML-21/22/23. No parser/dependency/package contract changed. + +The full pytest console command exposes Windows spawn entry-point and cp1252 +issues. The faithful recovery uses `PYTHONUTF8=1` and `uv run --no-sync python -m +pytest -q` for exact process-pool, source-state, Unicode and language cases. +Two symlink checks remain explicitly skipped on this host. The one changed +diagnostic assertion now expects `reassigned_declaration` and an empty receiver +declaration ID; its no-target assertion is preserved. Loader lifecycle recovery +assertions bind idempotency to the repaired products, not an obsolete source mtime. + +The remaining sixty failures reproduce individually on the original source with +the same Python runtime and `-X utf8 -m pytest -p no:cacheprovider --tb=short`: +29 unavailable bash/sh cases, five unavailable FIFO/Unix-socket/symlink fixtures, +two deleted-working-directory Windows fixtures, fourteen missing OpenAI/Solidity/ +Visual-Basic dependencies, eight unchanged installer/platform expectations, one +Terraform Windows path expectation and one actual read-only replacement defect. +No assertion or required check is removed or weakened. Baseline equality is +compatibility evidence, not a successful full gate. INC-QML-23 owns the real +read-only retention defect; absent shells/dependencies are separate limitations. + +Full typing also remains a failing gate. The baseline and correction comparison +uses identical basic-mode Python 3.10 project configuration and Pyright 1.1.409; +diagnostics compare relative file, severity, rule and message, allowing line shifts. +Explicit pinned-runtime checks likewise add no diagnostic. The final frozen +source check analyzes 585 files: 647 errors/4 warnings, 20.67 seconds; all 651 +diagnostics match the original baseline, with zero added or removed. Local +diagnostic files and original-source archives remain ignored. + +INC-QML-21/22/23 are separately scoped after the exit review. Broader adoption, +inherited event lookup and overload identity remain INC-QML-08/11/15; bounded +loader success does not imply every Qt SDK API or runtime effect is modeled. + +## Follow-up source acceptance (INC-QML-21–27) + +Baseline for the original reproductions is `1128205`; INC-QML-17–20 were committed +separately before these follow-ups. Local platform: Windows, Python 3.12.14, +tree-sitter 0.25.2 and language-pack 0.11.0. Tests use offline synthetic public +fixtures and production extract/build/update/consumer boundaries; no Qt application +or build hook runs. Final AST schema 9 and Qt policy 12 retire older same-version +qualified IDs, SDK-derived facts and handler parameter authority. + +Focused commands use `python -B -X utf8 -m pytest -q --tb=short`; the exact +file selections below retain test/criterion mappings in traceability. The full +gate and reviewed installed artifact are separate pending checks at this checkpoint. + +| Increment | Executed source selection and result | Genuine reproduction/review boundary | +| --- | --- | --- | +| INC-QML-21 | qualified identity/proof/updates, construction authority, constructor ownership, nested/CLI, native definition/constructor ownership: 152 passed, 14.21 seconds; explicit optional-index type narrowing then 43 related cases passed | Initial 7 failed/1 passed; signature/proof controls corrected; 51 using namespaces initially fail while 2 remain ambiguous, then both pass without truncating authority | +| INC-QML-22 | reflection identity/updates and orphan controls: 131 passed, 45.63 seconds | 12 false-target rejections fail before the correction; strict watch parity initially exposes four orphan failures, corrected by INC-QML-26 | +| INC-QML-23 | atomic replacement/readonly caller, prior atomic/export/source-state/loader-update compatibility: 79 passed, 3 baseline platform skips, 18.41 seconds | Actual readonly destination failure reproduces on original source; injected landing/cleanup/second-restore faults preserve or explicitly retain recovery evidence | +| INC-QML-24 | SDK declarations, boundaries, provenance, updates and providers: 107 passed, 33.25 seconds. Included-header/SDK/native alias/definition/context/loader/reflection compatibility: 169 passed, 58.22 seconds | Eight direct forward controls fail; included-header correction then 20 failed/10 passed, promoted to 33 collected cases including transitive/order/unincluded/namespace/provenance/overflow | +| INC-QML-25/26 | final publication/setup/product/orphan selection: 65 passed, 1 host-symlink skip, 25.16 seconds; one subsequently added partial-setup cleanup regression passed separately | 16 initial caller failures; non-OS second rollback/cleanup 3 failures; setup 6 failures/2 controls, realpath 1 failure; exact old cache-entry bytes remain unchanged while new valid entries may be added | +| INC-QML-27 | native property notify, pure-QML formals and native notify update files: 58 passed, 21.81 seconds, no skips. Handler/adversarial/integration compatibility: 91 passed, 2 Windows-symlink skips; script/scope/native compatibility: 29 passed | Native notify 8 failed/11 passed; explicit-style 4 failed; actual native/QML/qmake lifecycle, manual/force/watch parse and post-graph manifest rollback now pass | + +The final artifact re-executes the current combined selections; overlapping source +counts are not additive. Historical excluded probes remain baseline evidence only. +New handwritten files measure at most 288 lines at this checkpoint; touched legacy +ceilings and extraction exits are recorded in design. Ruff and bounded explicit- +interpreter Pyright checks pass for the owning changes. Final full typing retains +baseline comparison rather than assuming a clean project gate. + +Peer review found and corrected real namespace-cap, included-forward, rollback +and setup diagnostic gaps. Documentation review verifies public manual wording, +exact test references, ownership, cache epochs and diagnostic recovery. Wider +adoption, inherited native signals and qualifier-aware event overload scope remain +INC-QML-08/11/15. No new hosted, other-platform, native Qt/device, clean all-extra +installation or process/power-loss atomicity evidence is inferred. + +## Final local follow-up integration evidence + +Reviewed source revision: `75a6c5123f3316f96c89df2a5133de05ccb68af4`. The five cohesive source commits are +`d854509` (INC-QML-21), `6a774de` (INC-QML-22/24), `3e2c01a` (INC-QML-23), +`a9b9c5a` (INC-QML-25/26) and `75a6c51` (INC-QML-27 and final cache epochs). +Each retains the required Codex co-author trailer. Shared requirements/design/ +traceability records form the same change set. Later evidence edits affect only +documentation, not the reviewed runtime or tests. + +### Installed reviewed artifact + +The Git index was exported to an ignored review directory, built with +`uv build --wheel --offline --project --out-dir `, and installed +into a separate Python 3.12.14 verification environment. The source checkout was +not moved. Wheel: `graphifyy-0.9.74-py3-none-any.whl`; SHA-256 +`220c2fa7bc558885a96170ca3bc29853ed3e950fbc06e4a53b74066850e111f2`. All 165 Graphify Python payloads +are byte-equal across reviewed index export, wheel and installed package. + +Declared core dependencies are installed; tree-sitter 0.25.2 and language-pack +0.11.0 remain pinned. PyYAML 6.0.3 is the existing source-suite dependency used by +the selected Markdown compatibility oracle, not a new declared Graphify runtime +contract. Its wheel was unavailable in the offline cache and was installed from +the registry for this verification environment only. No package manifests, +lockfile or parser dependency contracts changed. + +The final real-file driver imports the installed package before tests, verifies +that import origin and supports Windows process-pool spawning. Command shape: +`python -I -X utf8 + `. The 44-file selection includes all +new correction tests, prior receiver/provider/type/loader cases, native ownership, +handlers/scripts, atomic writes, public update/failure consumers and the complete +language-facade file. **1,198 passed, 52 skipped, 194.61 seconds**; no failing case. + +Skips are explicitly unverified: three existing atomic mode/symlink cases, one +publication symlink-target case, two external-symlink QML admission cases and 46 +language-facade cases requiring unavailable optional parsers. Collected identities +and progress outcomes agree for all 1,250 cases. These skips do not verify their +criteria/platform forms. Executed rejection tests cover the remaining ordinary +root/path/provenance boundaries; other-platform/native system proof remains open. + +Earlier artifact invocations are not passing gates: the first had 1,195 passes, +52 skips and three harness/dependency failures (non-importable `python -c` pool +driver, missing core dependency in an isolated child, absent YAML oracle). After +the real-file driver and declared core install, 1,197 passed and the YAML case +still failed. The final frozen environment and unchanged assertions produce the +green result above. The source/wheel payload hash remains identical throughout. + +### Contribution gates and compatibility limits + +| Executed command/check | Final result | +| --- | --- | +| `PYTHONUTF8=1 uv run --no-sync python -m pytest tests -q --tb=short` | 7,558 passed, 59 failed, 178 skipped, 3 warnings; 728.80 seconds. All 59 failure IDs match the recorded original baseline subset; no added failures. The formerly failing actual read-only atomic replacement now passes. | +| `ruff check .` | Pass for the canonical current source. | +| `uv run --no-sync pyright --outputjson` | Failing full gate: 647 errors/4 warnings across 605 files, 19.809 seconds. All 651 diagnostics match the original canonical baseline; zero added/removed. | +| `uv run --no-sync pyright --pythonpath --outputjson` | Failing full gate: 646 errors/6 warnings, 20.275 seconds. Zero added diagnostics; one original missing-build-import diagnostic is no longer present. | +| `python -m tools.skillgen --check` | 134 artifacts match committed and expected output. | +| `python -m tools.skillgen --audit-coverage`, `--schema-singleton`, `--monolith-roundtrip`, `--always-on-roundtrip` | All pass. | +| `uv lock --check --offline` | Pass; 210 packages resolved. | +| Public document checks | 22 documents; local links, exact tests and all 84 criterion assignments pass; no private-path/project/credential pattern. | + +The unchanged full-suite failures comprise unavailable shell/platform fixtures, +deleted-working-directory Windows controls, optional OpenAI/Solidity/Visual-Basic +dependencies, installer expectations and a Terraform Windows path expectation. +Full pytest and typing are **not green**. No assertion, rule, required check or +fixture is removed or weakened to obtain the bounded correction result. Local +installed acceptance does not imply a clean all-extra or other-platform install, +new hosted validation, publication or merge. + +### Graph navigation and increment exit + +The required AST-only `graphify update .` completed after code freeze and before +this documentation closure. Durable output has 21,171 nodes, 45,426 edges and +1,055 communities; graph, HTML and report were written. Scoped `explain +ProductPublication` returns its source owner and caller links. Navigation uses +scoped graph queries and the report; source/tests remain correctness evidence. +Unavailable optional fixture-language parsers and the existing partial Luau +fixture are disclosed by the refresh, not treated as complete language analysis. + +INC-QML-21–27 close their locally verified bounded correction scope. Plan review +retains existing INC-QML-08/11/15 and explicit SDK/runtime/platform/system exclusions. +No further concrete increment is confirmed by the completed peer review. The +exposure chapter and named macros have explicit mechanism/evidence mappings; +their review is not a promise to model every Qt API or runtime behavior. + + +## Remaining adoption delivery baseline + +Local delivery continues from `5c0f2cab8bd8c14a7cb139c90997025b58f1c81c` on the +existing feature branch. Upstream v8 advanced to `48d7c0e` (v0.9.75); incoming +changes and the five existing stacked fork pull requests were inspected read-only. +No upstream integration, new pull request, publication or merge is performed by +this local delivery. Disjoint agents own native ancestry, constructor identity and +adoption joins; the integration owner owns shared docs, cache epochs and Git. + +## INC-QML-11 inherited endpoints + +The first real-facade grandparent regression failed before implementation: +`python -m pytest tests/test_qt_inherited_endpoints.py -q --tb=short` (one collected +case at that revision): **1 failed, 2.79 seconds**. Adjacent external typed-pointer +controls then exposed nonpublic inheritance authority: +`python -m pytest tests/test_qt_inherited_access.py -q --tb=short`: +**9 failed, 1.37 seconds** before the source-access correction. + +The final dedicated command uses the pinned Python 3.12 environment: +`python -m pytest tests/test_qt_inherited_access.py + tests/test_qt_inherited_endpoints.py tests/test_qt_inherited_updates.py -q --tb=short`. +It passed **43 cases, zero skips, 14.72 seconds**; integration-owner rerun passed +**43 cases, zero skips, 15.01 seconds**. These exercise original spans, exact +canonical members, complete-class rejection, diamond/role/signature ambiguity, +32-class bounds, public base access, durable JSON/query/affected, actual +manual/watch header/member/base edits, malformed input, real read-only manifest +retention, repaired retry and unchanged repeat parity. Ruff and targeted Pyright +pass; these targeted checks do not replace full contribution gates. + +Policy 13 invalidates unchanged derived Qt analysis; this increment does not +change AST schema 9. The installed artifact and shared final gates remain pending +until the constructor/adoption source freezes. A source-only explicit emission +was lost after a header signal rename; an isolated ordinary-production regression +failed **1 case, 0.98 seconds**. INC-QML-28 now owns occurrence admission and its +separate lifecycle proof. That pending gap prevents whole native criteria from +being declared Verified on this lookup result alone. + +## INC-QML-15 constructor signature identity + +The generic producer assigns constructor signature IDs before deduplication. +Declaration/body joins require exact signature, source-range and complete owner +proof. Unsupported signatures retain distinct source occurrences and cannot +borrow another accepted callable. AST schema 10 and Qt policy 14 invalidate +earlier constructor identities; D20 defines the migration boundary. + +The final agent selection passed **156 cases, zero skips, 27.08 seconds** across +constructor identity/type-authority/update, native consumer and ownership suites. +An adjacent angle-include alias counterexample first failed **2 cases, 0.97 +seconds** and now rejects possible admitted source shadows. Peer review exposed +qualified `void` incorrectly normalizing to an empty parameter list: the ordinary +regression selection first recorded **3 failed, 38 passed, 2.27 seconds**. The +correction retains the plain-void and pointer/CV distinctions. Integration-owner +command `python -m pytest tests/test_cpp_overload_identity.py + tests/test_cpp_overload_type_authority.py tests/test_cpp_overload_updates.py + tests/test_qt_overload_consumers.py tests/test_cpp_constructor_ownership.py + tests/test_qt_constructor_ownership.py -q --tb=short` passed **146 cases, zero +skips, 26.69 seconds**. Subsequent peer source-range rejection is part of this same +exact-span contract; its final result is recorded with the frozen integration. + +Lifecycle checks use actual cache entries and durable products, including +signature edits/removal, unmatched definitions repaired by header-only changes, +parse rejection, Windows read-only replacement and repaired retry. A historical +name-ID fixture migrates unchanged sources through both update modes. Targeted +Ruff and Pyright pass. A broader pinned-environment compatibility run had one +missing-YAML-oracle frontmatter failure; this is an environment diagnostic, not +accepted source behavior. Final full gates and installed proof remain pending. + +The promoted source-integrity probes first failed **3 cases, 0.82 seconds**. +The correction requires unique accepted-file size authority for every class and +constructor, plus strict original byte/point containment for prototypes and inline +bodies. It performs no source reread. The eight-module constructor/qualified-ID +selection passed **172 cases, 27.61 seconds**; final integrity and qualified +proof/update controls passed **46 cases, 3.72 seconds**, including 14 integrity +cases and real incomplete/dynamic include controls. Targeted lint and typing pass. + +## INC-QML-28 explicit source emissions + +The agent's event/ancestry/ownership/lifecycle selection passed **156 cases, zero +skips, 55.82 seconds**. Initial missing-declaration admission probes failed before +the collector opt-in. Peer regression exposed mechanism precedence and rejected +marker recovery: the ordinary source module first recorded **6 failed, 20 passed, +1.74 seconds**. After correction, emission/update/inherited-update/event consumer +selection passed **46 cases, 28.81 seconds**. + +Computed receiver syntax then exposed a false inner-factory signal occurrence. +Its promoted regression failed **2 cases, 26 deselected, 0.91 seconds**. Full outer +AST extent and terminal-call authority correct it without a computed endpoint +guess. `python -m pytest tests/test_qt_explicit_emissions.py + tests/test_qt_explicit_emission_updates.py tests/test_qt_signals_slots.py -q + --tb=short` passed **40 cases, 13.63 seconds**. Shared-scanner loader boundary, +provider and provenance regressions additionally passed **65 cases, 5.47 seconds**. +Ruff and targeted Pyright pass. Peer rerun excludes all 14 original constructor +and emission counterexamples. Qt policy 15 refreshes derived observations; AST +schema 10 is unchanged. Final installed and compatibility gates remain pending. + +## INC-QML-08a project and header adoption + +The original public qmake selection failed **12 cases, 11 passed, 0.62 seconds**; +adjacent lexical path controls then failed **2 cases, 29 passed, 0.58 seconds**. +Final metadata/native project compatibility selection passed **176 cases, 22.96 +seconds**. Header whitespace/inert-marker regression first failed **11 cases, +5 passed, 1.08 seconds**. The integration-owner command `python -m pytest + tests/test_qt_qmake_adoption.py tests/test_qt_qmake_adoption_integration.py + tests/test_qt_header_adoption.py tests/test_qt_header_updates.py -q --tb=short` +passed **69 cases, zero skips, 15.54 seconds**. This includes Objective-C priority, +plain C, optional-parser-independent classification, 256 KiB prefix, original +BOM/CRLF/Unicode spans, actual source-dispatch switching, metadata-only updates, +real cache retention and Windows read-only failure/repaired retry. + +AST schema 11 and Qt policy 16 retire older producer/derived facts. Project inputs +and build hooks remain unexecuted. New helpers/tests remain below 300 lines; the +facade shrinks within its existing ceiling. Installed whole-root/safe-subroot +acceptance and final full gates remain pending under INC-QML-08c. + +## INC-QML-08b and INC-QML-29 declared APIs + +Pointer-shape ordinary regressions first recorded **16 failed, 14 passed, 3.91 +seconds**. The corrected declared provider/consumer/update selection passed **194 +cases, 55.51 seconds**, with final original-byte shape controls **38 passed, 4.70 +seconds**. Independent source/consumer review then found missing authoritative +member/accessor proof and legacy native parameter ordering. Promoted ordinary +integrity/implicit tests first recorded **21 failed, 5 passed, 4.12 seconds**. + +The frozen adoption/provider/native/QML/combined/affected-direction selection +passed **268 cases, 67.51 seconds**. Final proof/implicit/update command `python + -m pytest tests/test_qt_declared_proof_integrity.py + tests/test_qt_context_implicit_subscriptions.py tests/test_qt_typed_provider_updates.py + -q --tb=short` passed **46 cases, 32.74 seconds**, including six added +default/directed NOTIFY reload and C++-only parameter retirement/repair controls. +Peer rerun passed all **20 cases, 3.02 seconds**, excluding its fifteen original +failures while retaining five explicit-handler/local-callback controls. Targeted +Ruff/Pyright pass with zero added diagnostics in those helpers/tests. + +Reference-return canonical admission first failed **1 case, 0.94 seconds** at +missing generic target. The eight-line generic declarator correction and promoted +ordinary tests passed **22 cases, zero skips, 16.58 seconds**. Broader generic, +constructor, native and adoption selection passed **288 cases, 4 skipped, 32.62 +seconds**. Those four existing preprocessor skips require unavailable host cpp; +they are not acceptance. Engine typing retains 33 existing diagnostics overall, +with none in the changed helper boundary. The legacy engine measures 7,728 lines +within its 7,730 ceiling. New helpers/tests remain below 300 lines. + +08b/29 share a reviewable canonical endpoint integration: AST schema 12 and Qt +policy 17 retire old declaration/callback/generic facts. Return references lend +declared APIs without proving QVariant conversion, allocation, const-correct +invocation or runtime identity. Installed final integration remains pending; +undirected affected omission is separately reproduced and assigned to INC-QML-30. + +## INC-QML-30 affected-query direction + +Two actual pointer-factory/direct-provider probes first failed after production +JSON reload (**2 failed, 1.18 seconds**). Expanded direction/member cases recorded +19 failures with four controls; source-owner promotion added four failures with +three controls. On-disk corrupted-marker reload recorded **8 failed, 1.49 seconds**. +The correction validates source direction at traversal, member seeding, source +promotion and reload without changing graph data. + +`python -m pytest tests/test_qt_affected_direction.py + tests/test_qt_affected_direction_updates.py tests/test_affected_member_seed.py + tests/test_affected_cli.py tests/test_qt_affected_consumers.py + tests/test_qt_affected_definitions.py tests/test_js_dynamic_import_affected.py + tests/test_qt_native_property_notify.py tests/test_qt_native_notify_updates.py + -q --tb=short` passed **146 cases, zero skips, 36.43 seconds**. Both graph modes, +reversed insertion, legacy unmarked orientation, parallel mechanisms, corrupted +identity, depth/cycles, CLI provenance and actual manual/watch stale-edge repair +pass. Ruff and targeted Pyright pass for all six touched source/test files. +Installed integration and the separate INC-QML-31 serialization correction remain +pending; a consumer pass cannot prove a serializer preserved rejected transport. + +## Callback source correspondence + +The promoted ordinary callback module first recorded **17 failures** before +correction; an adjacent borrowed-handler span case also failed before its fix. +Actual raw/build/JSON/reload tests reject a substituted same-component callback, +borrowed scope/owner/span/form/arity, and a QML function displacing a captured +local JavaScript declaration. Qualified and own-object callback controls retain +their source lookup. The final 17-module adopted/native/QML/combined/affected +selection passed **310 cases, zero skips, 73.38 seconds**. Ruff and explicit-runtime +targeted Pyright pass for the complete owned slice. The source validator is 110 +lines; projection 83 and callback tests 104 remain within their ceilings. + +## INC-QML-31 JSON direction integrity + +The direct production-writer regression first recorded **33 failed, 2.16 seconds**: +the old writer returned success and committed invalid transport. The final direct +and real clustered-update selection passes all **53 new cases, 5.90 seconds**. +Actual default/directed native JSON/reload/affected, generic graph variants, +preflight before external connection, prior byte retention, source proof and +repaired repeat cover the same serializer path. Four clustered manual/watch +cases retain seven durable products and existing nonempty AST cache bytes exactly +on rejection. Corrected output matches clean source contracts; presentation-only +community labels are excluded only from the clustered-versus-unclustered oracle. + +`python -m pytest tests/test_qt_json_direction.py + tests/test_qt_json_direction_updates.py tests/test_qt_export_matrix.py + tests/test_qt_qml_export_consumers.py tests/test_export.py tests/test_atomic_writes.py + tests/test_export_idempotent_writes.py tests/test_cli_export.py + tests/test_js_dynamic_import_affected.py tests/test_python_import_resolution.py + tests/test_typescript_enum_members.py tests/test_qt_affected_direction.py + tests/test_qt_affected_direction_updates.py -q --tb=short -rs` passed **307 cases, +4 skipped, 59.24 seconds**. Skips are existing optional matplotlib SVG, Windows +POSIX-mode equality and unavailable symlink setup/creation controls. No new case +skips. Ruff passes; targeted pinned typing retains three existing optional +matplotlib import diagnostics outside the changed lines. Final full typing and +installed evidence are recorded separately. No source/Qt epoch advances here. + +## Final broad gate reproduction + +The first expanded reviewed optional wheel matched all **178 Python payloads** +byte-for-byte but its suite recorded **20 failed, 2,506 passed, 36 skipped, +491.84 seconds**. Seven failures exposed valid C++ file provenance rejected by +the old membership guard. Thirteen containment fixtures assumed newly supported +constructor overloads lose generic ownership. Their fixture now uses equivalent +duplicate bodies, preserving every original rejection/containment assertion; +that faithful selection isolates **5 failed, 8 passed** before INC-QML-33. +These results are failed integration evidence and are superseded only by a fresh +reviewed artifact after correction. + +The concurrent full source run recorded **83 failed, 8,092 passed, 178 skipped, +3 warnings, 958.87 seconds**. Its 24 additional failures include those twenty and +four clustered source-oracle tests collected before their final test freeze. +Those four compared presentation community labels with an unclustered cold +oracle; the final independent installed run passes all four with exact source +contracts retained. A fresh complete source run is required after the new consumer +corrections. Neither the old artifact nor this failed run establishes acceptance. + +## INC-QML-32 first-publication repeat + +An additional isolated installed CLI check produced correct public Qt facts but +rewrote its unchanged second raw graph. The promoted ordinary suite first +recorded **15 failed, 32 passed, 8.20 seconds**: ten actual CMake/qmake, +whole-root/subroot and generic Python manual/watch repeat cases plus five +missing-versus-empty comparison cases. The narrow correction passed **47 cases, +14.14 seconds** with bytes/mtime, real edits and replacement rejection/recovery. +Ruff passes; new tests have zero targeted type diagnostics. Legacy watch typing +retains separately recorded baseline diagnostics outside the changed seam. + +## INC-QML-33 file provenance compatibility + +The unchanged membership cases initially recorded seven failures. Correcting the +obsolete containment overload fixture without weakening its assertions isolated +five further valid file-role failures. Shared transport/file-role correction passes +`python -m pytest tests/test_qt_cpp_file_roles.py tests/test_qt_project_membership.py + tests/test_qt_project_membership_updates.py tests/test_qt_source_file_containment.py + tests/test_cpp_overload_type_authority.py tests/test_cpp_overload_source_integrity.py + tests/test_cpp_constructor_ownership.py tests/test_cpp_overload_identity.py + tests/test_cpp_qualified_identity.py tests/test_cpp_qualified_proof.py -q --tb=short`: +**300 passed, zero skipped, 28.41 seconds** on the pinned Python/parser environment. +Eighty new cases cover both joins, legacy/current/incomplete/fresh/reloaded proof, +33 corruption forms and separate constructor authority. Ruff and targeted Pyright +pass. An additional real C++ deletion probe reproduces orphan include-stub retention; +INC-QML-35 owns its correction. It is not passing lifecycle evidence yet. + +## INC-QML-34 preflight before comparison + +The actual builder/deduper-seam suite first recorded **64 failed, 4 passed, +30.86 seconds**. The corrected preflight passes **68 cases, zero skipped, +57.08 seconds**. Raw/clustered and manual/watch profiles reject partial/foreign, +ill-typed, collapsed and native-directed contradictory markers before shortcuts +or writers; force does not bypass integrity. Prior product bytes/mtime and real +successful cache entries remain unchanged, candidates are not mutated, and a +repaired retry/repeat succeeds before a genuine source edit publishes. Ruff and +targeted helper/test Pyright pass. The unchanged INC-QML-32 regressions remain +separate; fresh full-source and installed integration gates follow source freeze. + +## INC-QML-35 prior import proof + +The initial real C++ deletion reproduction records **2 failed, 3.32 seconds**. +Expanded source-proof controls also reproduce two origin-laundering failures; +the updater now captures original persisted proof before provenance inference. +`python -m pytest tests/test_qt_cpp_file_role_updates.py tests/test_qt_orphan_cleanup.py + tests/test_qt_orphan_import_proof.py -q --tb=short` passes **97 cases, zero skips, +37.06 seconds** on the pinned environment. Forty-two lifecycle and 43 focused +prior-proof cases preserve connected, semantic, foreign and source-backed roles, +ordinary/hyperedge references, exact corrupted-marker conservatism, real Windows +read-only rejection, nonempty successful cache entries and repaired repeat. +Ruff and focused Pyright pass. Watch is 2,526 lines before INC-QML-38; the pure +cleanup owner is 89 lines, with new tests 258 and 144 lines. + +## INC-QML-37 repeated event transport + +The promoted ordinary suite records **28 failed, 3.95 seconds** before correction: +one site recursively encodes literal maps, two fail later metadata decoding, +and three or more fail extraction. Its first narrow encoder fix exposes stale +native role direction; complete per-site/canonical-role correction passes +**28 cases, 9.51 seconds**. The final suite adds two actual Windows publication +rejection/retry cases. `python -m pytest tests/test_qt_cross_event_occurrences.py + tests/test_qt_access_providers.py tests/test_qt_event_incremental_consumers.py + tests/test_qt_events_boundaries.py tests/test_qml_cpp_access.py + tests/test_qt_qml_export_consumers.py tests/test_qt_cpp_upgrade_invalidation.py + -q --tb=short -rs` passes **69 cases, zero skips, 21.71 seconds**. +Ruff and explicitly bound targeted Pyright pass. The event owner is 115 lines +and the ordinary regression module 175, within the 300-line source ceiling. +AST schema remains 12; Qt policy 18 refreshes derived events without changing +the package version or executing the analyzed project. Final artifact proof follows. + +## INC-QML-36 source mechanism integrity + +Independent review first accepts a property-read edge relabeled `calls` at the +projection boundary. Expanded ordinary cases record **88 failed, 46 passed** +before correction, including the separately corrected repeated event transport. +Actual builder assertions also reproduce a `uses` bypass of its generic relation +gate. The new module covers **166 cases across 18 mechanisms**. The final command +`python -m pytest tests/test_qt_bridge_mechanism_integrity.py + tests/test_qt_loader_providers.py tests/test_qt_loader_boundaries.py + tests/test_qt_loader_updates.py tests/test_qt_receiver_boundaries.py + tests/test_qt_native_notify_updates.py tests/test_build.py -q --tb=short` +passes **394 cases, zero skips, one existing Hypothesis warning, 63.12 seconds**. +The context/native/callback/proof/combined compatibility selection separately +passes **304 cases, zero skips, 50.11 seconds**, with that existing warning. +Ruff and scoped projection/test Pyright pass. Build grows 2,469 to 2,475 lines; +projection/new tests are 185/188 lines. The source/test snapshot is frozen for +fresh full-source and reviewed installed-artifact proof. + +The outer-metadata watch probe performs four actual raw updates against string/ +list metadata on a prior generic C++ file, using a sibling QML edit or C++ deletion. +Fresh extraction reconstructs the valid file transport or removes the deleted +file without using the malformed record as authority. All four return success +and expose no injected diagnostic string. This is watch evidence only; no manual +result is inferred from an incomplete setup. No further increment is needed there. + +## INC-QML-38 first external-stub origin + +The truly cold first-repeat suite records **22 failed, 6.99 seconds**, then +**22 passed, zero skips, 8.92 seconds** after stamping only newly minted stubs. +Independent peer execution also passes **22, zero skips, 8.99 seconds**. +`python -m pytest tests/test_qt_publication_stub_origin.py tests/test_qt_initial_repeat.py + tests/test_qt_update_direction_integrity.py tests/test_qt_cpp_file_role_updates.py + tests/test_qt_orphan_cleanup.py tests/test_qt_orphan_import_proof.py + tests/test_external_stub_endpoints.py tests/test_publication.py + tests/test_qt_product_publication.py tests/test_atomic_writes.py -q --tb=short -rs` +passes **308 cases, 4 existing platform skips, 146.05 seconds**. New cases do not +skip. The failure is an injected OSError at the production replacement boundary; +INC-QML-35 supplies complementary actual Windows read-only rejection. Previous +products/nonempty cache bytes, repair and first-repeat bytes/mtime are checked. +Ruff and targeted new-test Pyright pass. Watch is 2,532 lines; new tests 168. +No source schema/policy or comparison semantics changes here. + +## Final adoption delivery + +This proof closes local INC-QML-11, INC-QML-15, INC-QML-08a/b/c and the reproduced +INC-QML-28–38 corrections. Reviewed index tree: +`9f0ae4ea75f47539df8ec8a66f1493b9e7f52b2f`; final documentation closure does not change +the verified Python source/test payload. Profile: Windows x64, Python 3.12.14, +tree-sitter 0.25.2, tree-sitter-language-pack 0.11.0, pytest 9.1.1 in the pinned +installed harness; canonical source pytest 9.0.3, PyYAML 6.0.3. Source fixtures are +Qt 6 CMake/qmake inputs; no Qt SDK, corpus execution or model API is used. + +### Reviewed installed artifact + +`uv build --wheel --offline --project REVIEWED_INDEX --out-dir ARTIFACT_DIRECTORY` +builds `graphifyy-0.9.74-py3-none-any.whl`, SHA256 +`184abd15490322fd6f39b50c341a4da79b6cd19e96cd3f4fe3c68ab4c4d29a97`. A fresh +environment installs this noneditable wheel and pinned optional parser. The +isolated `-I -X utf8` driver imports the installed package before loading selected +tests from the reviewed index and asserts its installed path. All **179 Python +modules match byte-for-byte** between reviewed source, wheel and installation. +The selected compatibility/adoption suite executes **3,024 passed, 36 skipped, +649.07 seconds**. Newly added regressions do not skip; historical optional/platform +skips remain explicit and cannot establish their criteria. The selection extends +the prior compatibility list with every current Qt/QML, generic C++, affected and +Objective-C test module. Main-guard execution preserves the Windows process-pool +production boundary. This is offline local artifact proof, not hosted delivery. + +From a neutral temporary directory outside ignored paths, actual +`python -I -X utf8 -m graphify update ROOT --no-cluster` runs without mocks or a +source checkout import. Each profile has initial and unchanged-repeat exit 0, +hand-checked expected facts and byte-identical graph JSON: + +| Build metadata | Scope | Nodes | Edges | Initial/repeat | +| --- | --- | --- | --- | --- | +| CMake | Whole project | 102 | 125 | Passed / Passed | +| CMake | Configured safe application subroot | 100 | 124 | Passed / Passed | +| qmake | Whole project | 113 | 136 | Passed / Passed | +| qmake | Configured safe application subroot | 111 | 135 | Passed / Passed | + +Ordinary installed lifecycle regressions additionally exercise actual manual/watch +edits, cold/warm versus clean rebuilds, source removal, accepted-cache retention, +failure repair and bytes/mtime idempotency. Actual Windows read-only destination +denial supplements injected resolver/writer failures. The earlier failed artifact +and 83-failure preclosure source runs remain historical failed evidence, superseded +by this fresh corrected payload; they are not counted as passes. + +### Full contribution gates and retained exceptions + +`uv run --no-sync python -m pytest tests -q --tb=short`, with UTF-8 environment, +executes **8,630 passed, 59 failed, 178 skipped, three warnings, 1,096.87 seconds**. +Every failed node ID equals the recorded pre-work baseline +`5c0f2cab8bd8c14a7cb139c90997025b58f1c81c`: added **0**, resolved **0**. The baseline +run had 7,558 passes, the same 59 failures and 178 skips. The gate exits 1; no +assertion is weakened or skip treated as passing. Existing parser-backend and +Windows portability exceptions retain their earlier individual records. + +`ruff check .` passes. Explicitly bound full Pyright remains failed: canonical +`pyright --pythonpath .venv/Scripts/python.exe --outputjson` reports **645 errors, +four warnings**; pinned Python reports **644 errors, six warnings**. Against +recorded baseline `1128205820590132be140d03be4230bb8d6bec57`, diagnostic multisets +ignore line shifts and show **zero added**, with two/three removed respectively. +Focused new owners/tests pass targeted typing. A default invocation through the +unavailable system Python alias is environment-only output and not proof. + +`uv lock --check --offline` passes (210 packages). `python -m tools.skillgen +--check` passes for 134 generated artifacts; audit-coverage, schema-singleton, +monolith-roundtrip and always-on-roundtrip validators execute separately and pass. +Final document checks verify local links/anchors, exact test functions, privacy +patterns and assignments for all 85 stable criteria; no private corpus or machine +path is retained. Source/test freeze precedes full-source and installed execution. + +### Navigation graph and delivery limits + +`uv run --no-sync graphify update .` completes AST-only refresh: **22,121 nodes, +48,476 edges, 1,119 communities**; HTML aggregates 1,119 communities and 3,405 +cross-community edges. Optional backend warnings and the deliberate partial Luau +fixture remain visible. The graph is navigation material, not correctness proof. +Scoped queries use the explicit graph path and a 1,500-token budget. Its historical +commit metadata does not claim an exact final commit binding for working-tree code. + +Current source has not been pushed, merged, deployed or run in the twelve hosted +OS/Python lanes. Earlier hosted results do not verify this changed source. Native +browser/device and live database procedures remain unexecuted; no Qt runtime +behavior is inferred. The local implementation/acceptance scope is complete, while +global platform and release gates retain these explicit limitations. + +## Pre-INC-QML-00 failure attribution + +The immutable imported upstream source is +`0b60d47e6cd9338c51143f39f35b6c45c8453385`, before INC-QML-00 commit +`2f0fdcee314c408652993bc62341a6d92ed5d99f`. All thirteen test modules containing +the current 59 failures exist at that upstream revision and are byte-identical +after checkout newline normalization. Their existence alone does not establish +that they failed during the original foundation run. + +The foundation run above exercised six files and directly recorded only two of +these failures: the Windows deleted-current-directory watch cases. The later +`1128205` and `5c0f2ca` comparisons therefore cannot substitute for pre-increment +execution evidence. + +A retrospective replay exports the immutable upstream revision with `git archive` +into an ignored source artifact, without changing the canonical checkout or +branch. An isolated `python -I -X utf8` driver prepends that archive, verifies the +imported `graphify` package belongs to it, then executes exactly the 59 failed +node IDs using `pytest -q --tb=short -p no:cacheprovider`. The current canonical +Windows x64/Python 3.12.14 environment is retained; no dependency is installed or +source/test assertion changed. Result: **59 failed, 7.16 seconds**, with all 59 +failure identities matching and zero unmatched identities. This is a selective +replay of old source, not a historical full-suite run or a claim that every +failure had already been observed during foundation setup. + +The replay retains 29 unavailable shell/hook cases, seven Windows filesystem +fixture limitations, fourteen missing optional OpenAI/Solidity/Visual-Basic +dependencies, eight installer/assistant-skill assertions and one Terraform graph +expectation. Thus all 59 reproduce without the Qt/QML implementation on the +current host. Shell-security cases stop before the missing shell starts; they +establish neither successful security acceptance nor an injection vulnerability. +The full contribution gate remains failed, and this attribution does not remove +its baseline exceptions or fill unexecuted platform evidence. + +## Windows shell and optional dependency follow-up — 2026-10-04 + +This dependency-only follow-up tests unchanged source at +`6369e97f30e36ac2fd22d09c6ce2a216e9fbb81b` on Windows x64/CPython 3.12.14. +Production code, collected tests, generated skills, requirements and `uv.lock` +remain unchanged. The earlier full-suite and pre-increment replay totals remain +historical results for their recorded environments. + +Portable [Git for Windows 2.56.0](https://github.com/git-for-windows/git/releases/tag/v2.56.0.windows.1) +provides working `bash` and `sh`, both reporting Bash 5.3.15. The downloaded +`PortableGit-2.56.0-64-bit.7z.exe` matches its published SHA256 +`eceb5e061aa90df2f69ddd3e90f0030e1b8037a7829934bc40e4be1caa1accc1`. +Installation is portable and per-user; test subprocesses receive an explicit +PATH. No persistent system/user PATH change or WSL installation is claimed. + +The source test environment adds `openai==2.36.0`, +`tree-sitter-solidity==1.2.13` and `tree-sitter-vb-dotnet==0.3.0` with +`uv pip install --only-binary :all:`. Constraints retain all 88 existing dependency +versions and pin the fifteen added packages, including transitives, to `uv.lock`. +Tree-sitter remains 0.25.2 and language-pack remains 0.11.0. No compiler or +global Graphify upgrade is used. The SDK cases replace `openai.OpenAI` with a +mock before production calls; no provider request or API credential is required. + +| Executed selection | Passed | Failed | Skipped | Evidence boundary | +| --- | --- | --- | --- | --- | +| `tests/test_ollama_retry_cap.py`, `tests/test_solidity_extractor.py`, `tests/test_vbnet_extractor.py` | 17 | 0 | 0 | All fourteen former dependency failures and three existing controls | +| Exact 29 former missing-shell node IDs | 26 | 3 | 0 | Original assertions unchanged; three legitimate-path controls fail | +| Exact 59 original failure node IDs | 40 | 19 | 0 | Selective current-source rerun; no failure outside that selection | +| Complete `tests/test_hooks.py` and `tests/test_skillgen_input_path_injection.py` | 138 | 5 | 8 | Includes two previously skipped hook cases now failing path-spelling assertions | + +The pytest invocation uses the source environment's Python with +`-I -X utf8`, an explicit source-import assertion, and +`pytest -q --tb=short -p no:cacheprovider -rs --junitxml=REPORT`. +Literal parameterized node IDs are passed as argument-array entries. Per-stage +logs, JUnit reports, commands, dependency constraints and bounded comparisons +are retained in ignored `.venv/bash-verification/`. Failure identities come from +JUnit testcase failure/error elements; `-rs` alone does not print failure IDs. + +The shell subprocess PATH excludes global `uv`, `pip` and Graphify launchers. +A test-only `python3` shim forwards arguments unchanged to the real native proof +interpreter and rejects `-m pip`; interpreter imports are from the selected source. +Tool/cache/package destinations are isolated and installation fallbacks are +offline. Existing home-directory fixture isolation is retained, with additional +AppData isolation. The global launcher, unrelated coverage data and working tree +are preserved. This controls the installation-discovery boundary while exercising +the actual shipped shell blocks, not a reimplementation of their input handling. + +### Shell portability and security evidence limits + +`tests/test_skillgen_input_path_injection.py::_run_step1` rewrites Windows paths +to `/mnt/c/...`. The generated Step 1 passes that value through stdin to native +Python `os.chdir`; Git Bash argument conversion cannot translate stdin. Thus +`test_step1_still_resolves_a_legitimate_path` fails for all three shipped skill +files. An unreachable hostile sentinel can also make an original negative case +pass without proving injection resistance. + +Supplementary controls execute the unchanged committed Step 1 blocks with native +literal `C:/...` paths, including spaces. All **26 checks pass**: three successful +interpreter/root-marker controls, three missing-path rejection controls and twenty +hostile-input checks. Relative sentinels are verified against deliberately unsafe +`true INPUT_PATH` controls through the same Bash invocation; all **20 unsafe +controls create the expected sentinel**. This proves reachability and supports the +shipped-block result for that bounded profile. It does not convert the three +original fixture failures into passes or provide ordinary collected regression +coverage for a harness correction. + +The newly executable +`tests/test_hooks.py::test_uv_tool_env_rescues_hook_when_pin_and_launcher_fail` and +`tests/test_hooks.py::test_shebang_parse_requires_leading_hash_bang` report MSYS +`/tmp/...` interpreter paths while asserting literal native Windows path strings. +Both remain failing portability assertions. No test is changed, skipped or +weakened to hide these discrepancies. + +The original failure selection retains eight installer/assistant-skill cases, +seven Windows filesystem fixture cases, three Step 1 path fixtures and one +Terraform expectation: **19 failures**. The two newly executable hook failures +are additional to that historical selection. The entire source suite has not +been rerun in the expanded environment; no new global failure/skip total or +passing contribution gate is inferred. Hosted OS/Python lanes, native interaction +and live service proof remain outside this local dependency follow-up. diff --git a/docs/qt-qml/VIEWER_SYSTEM_REVIEW.md b/docs/qt-qml/VIEWER_SYSTEM_REVIEW.md new file mode 100644 index 0000000000..a5253e13f6 --- /dev/null +++ b/docs/qt-qml/VIEWER_SYSTEM_REVIEW.md @@ -0,0 +1,51 @@ +# Native graph navigation review + +Status: procedure defined; execution remains unverified. Owner: HTML exporter +maintainer, with the reviewer operating the physical device. Acceptance: +REQ-QML-021-AC01–AC03 and REQ-QML-019-AC04. Emitted-script automation remains in +[traceability](../../tests/TRACEABILITY.md); it does not substitute for this check. + +## Fixture and evidence + +Export the public Graphify repository graph using the reviewed installed wheel. +Retain the source commit, wheel SHA-256, graph/HTML SHA-256, export command/result, +browser/version, operating system, display scale and mouse/device model. Use a +physical mouse with a middle button. Repeat with a source-node export and a +community aggregate export, and at normal, zoomed-in and zoomed-out camera scales. +Do not use private source or account pages as retained evidence. + +## Action and expected result + +1. Open the exported HTML in each declared browser/platform lane. Every exported + community starts selected, the graph is visible, and no Overview control exists. +2. Hold the middle button and drag horizontally, vertically and diagonally. + The camera follows smoothly in both directions at each scale. Nodes and zoom + remain unchanged; browser autoscroll and text selection do not activate. +3. While holding the button, move outside the canvas and release. Return and move + without pressing: the camera must stay still. Repeat with focus loss, tab change + and page exit; each fresh middle press starts an independent gesture. +4. Use wheel zoom, left-button node selection, the inspector, community filters, + Select All/None and a search that reveals a filtered node. They remain usable + after the middle gesture. Confirm raw graph and HTML input hashes are unchanged. +5. Where supported, repeat with right-button and touch interaction. Record an + explicit applicability reason for any unavailable input or platform lane. + +Record criterion-by-criterion results, screenshots or a short capture, and relevant +browser console errors. A visible graph alone does not establish gesture cleanup, +input coexistence or all platforms. A lane is Verified only when its applicable +steps pass with the recorded artifact identity. + +## Failure, cleanup and retry + +Failure signatures include unwanted autoscroll, wrong-axis or zoom-dependent jumps, +continued panning after release/focus loss, stuck cursor/selection suppression, +broken filters/inspection or changed data. Preserve the first failure and its +artifact/environment evidence; add a production regression at the faithful boundary. +Do not edit the exported payload or expected behavior to clear the result. + +Release all buttons, restore focus and close the test tab. If capture or cursor +state remains stuck, reload the unchanged artifact and record that recovery was +required. Remove only the reviewer's explicitly created temporary exports after +retaining safe evidence. There is no server or persistent camera state to roll +back. Retry after identifying the owning failure and cleanup; report unavailable +device/browser access as an open system gap, not a pass. diff --git a/graphify/__main__.py b/graphify/__main__.py index 5f5750b885..38a6719f68 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -774,6 +774,7 @@ def _run_cli() -> None: print(" --force overwrite graph.json even if the rebuild has fewer nodes") print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)") print(" --no-cluster skip clustering, write raw extraction only") + print(" --follow-symlinks opt into contained symlink discovery (default: off)") print(" cluster-only rerun clustering on an existing graph.json and regenerate report") print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)") print(" --graph path to graph.json (default /graphify-out/graph.json)") diff --git a/graphify/affected.py b/graphify/affected.py index 0184a8f88f..3b8118e638 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -1,13 +1,15 @@ from __future__ import annotations -from collections import deque +from collections import defaultdict, deque from dataclasses import dataclass from pathlib import Path -from typing import Iterable +from typing import Iterable, cast import unicodedata import networkx as nx +from graphify.graph_direction import logical_endpoints + DEFAULT_AFFECTED_RELATIONS = ( "calls", @@ -206,13 +208,28 @@ def affected_nodes( # otherwise. The member nodes are seeds only (not reported as hits), and # `method`/`contains` stay out of the general relation-filtered walk, so this # adds no forward noise anywhere else. + directed = graph.is_directed() + incoming_by_target = defaultdict(list) if hasattr(graph, "out_edges"): - member_edges = graph.out_edges(seed, data=True) + member_edges = cast(nx.DiGraph, graph).out_edges(seed, data=True) else: - member_edges = ( - (s, t, d) for s, t, d in graph.edges(data=True) if s == seed - ) + # Index accepted logical pairs once. Undirected adjacency iteration + # cannot recover direction; rescanning every edge per BFS node is also + # unnecessary. Directed graphs retain their efficient native indexes. + member_edges = [] + for source, target, data in graph.edges(data=True): + endpoints = logical_endpoints(source, target, data, directed=False) + if endpoints is None: + continue + source, target = endpoints + incoming_by_target[target].append((source, target, data)) + if source == seed: + member_edges.append((source, target, data)) for _s, member, data in member_edges: + endpoints = logical_endpoints(_s, member, data, directed=directed) + if endpoints is None or endpoints[0] != seed: + continue + _s, member = endpoints if str(data.get("relation", "")) not in ("method", "contains"): continue member = str(member) @@ -225,14 +242,14 @@ def affected_nodes( if current_depth >= depth: continue if hasattr(graph, "in_edges"): - incoming = graph.in_edges(current, data=True) + incoming = cast(nx.DiGraph, graph).in_edges(current, data=True) else: - incoming = ( - (source, target, data) - for source, target, data in graph.edges(data=True) - if target == current - ) + incoming = incoming_by_target.get(current, ()) for source, _target, data in incoming: + endpoints = logical_endpoints(source, _target, data, directed=directed) + if endpoints is None or endpoints[1] != current: + continue + source, _target = endpoints relation = str(data.get("relation", "")) if relation not in relation_set: continue @@ -251,6 +268,17 @@ def affected_nodes( ) hits.append(hit) queue.append((source, current_depth + 1)) + # Versioned source occurrences carry the dependency; their actual + # extracted owners are affected at the same dependency distance. + from graphify.qt_affected import owned_ancestors + for owner, ownership in owned_ancestors(graph, source): + if owner in seen: + continue + seen.add(owner) + hits.append(AffectedHit(owner, current_depth + 1, relation, + via_file=str(data.get("source_file") or "") or None, + via_location=str(data.get("source_location") or "") or None)) + queue.append((owner, current_depth + 1)) return hits diff --git a/graphify/build.py b/graphify/build.py index 871b927987..3a125f9384 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -148,6 +148,7 @@ def mint_external_stubs_in_data(data: dict) -> None: # edge (#1547/#1556) is dropped. Kept local to build.py (not imported from extract.py, # which imports build.py — a cycle) and deliberately mirrors extract._LANG_FAMILY_BY_EXT. _EDGE_LANG_FAMILY: dict[str, str] = { + ".qml": "qml", ".py": "py", ".pyi": "py", ".js": "js", ".mjs": "js", ".cjs": "js", ".jsx": "js", ".ts": "js", ".tsx": "js", ".mts": "js", ".cts": "js", @@ -1383,8 +1384,19 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat # bind to a same-named node in another language. The extraction spec forbids # this for `calls`; it is equally invalid for `imports`/`references` (a # Python `import time` must not bind to a `time.ts`, #1749). + # Typed Qt/QML bridges also use `uses` for property access, emissions and + # loading. Validate their source mechanism before the generic relation + # gate so changing a relation cannot bypass endpoint/source authority. + from graphify.qt_qml_projection import allows_qt_qml_edge, is_typed_qt_qml_edge + if is_typed_qt_qml_edge(G.nodes[src], G.nodes[tgt], attrs) and not allows_qt_qml_edge( + G.nodes[src], G.nodes[tgt], attrs, source_id=src, target_id=tgt, nodes=G.nodes): + continue _edge_rel = attrs.get("relation") if _edge_rel in ("calls", "imports", "imports_from", "references"): + from graphify.qml_projection import allows_qml_script_edge + qml_script_edge = allows_qml_script_edge(G.nodes[src], G.nodes[tgt], attrs, target_id=tgt) + qt_qml_edge = allows_qt_qml_edge(G.nodes[src], G.nodes[tgt], attrs, + source_id=src, target_id=tgt, nodes=G.nodes) src_ext = Path(G.nodes[src].get("source_file") or "").suffix.lower() tgt_ext = Path(G.nodes[tgt].get("source_file") or "").suffix.lower() src_fam = _EDGE_LANG_FAMILY.get(src_ext) @@ -1395,13 +1407,14 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat if ( attrs.get("confidence") == "INFERRED" and src_ext and tgt_ext and src_fam != tgt_fam + and not (qml_script_edge or qt_qml_edge) ): continue else: # imports/references: drop only when BOTH endpoints are known code # languages of different families, so a config->code reference # (unknown ext, e.g. a manifest) is never mistaken for a phantom. - if src_fam is not None and tgt_fam is not None and src_fam != tgt_fam: + if src_fam is not None and tgt_fam is not None and src_fam != tgt_fam and not (qml_script_edge or qt_qml_edge): continue # A file-level import or re-export cannot carry useful connectivity when # both endpoints resolve to the same node. This most often happens when diff --git a/graphify/cache.py b/graphify/cache.py index f9031f7597..060aa90f98 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -36,7 +36,10 @@ _EXTRACTOR_VERSION = "unknown" # Bump when AST cache-key semantics change independently of the package version. -_AST_CACHE_SCHEMA = 5 # Python receiver-shadow facts in persisted raw calls. +# The integrated producer carries both Qt API/callback/reference-callable facts +# and upstream Python receiver-shadow facts. Neither schema-12 Qt entries nor +# schema-5 upstream entries establish that combined transport contract. +_AST_CACHE_SCHEMA = 13 # Version dirs already swept this process — cleanup runs once per (base, version). _cleaned_ast_dirs: set[str] = set() diff --git a/graphify/callflow_html.py b/graphify/callflow_html.py index 3c8a34b41f..e0c73c11b0 100644 --- a/graphify/callflow_html.py +++ b/graphify/callflow_html.py @@ -572,7 +572,8 @@ def preferred_edges(edges: list, allow_structure: bool = False) -> list: if not should_include_edge(edge): continue relation = edge.get("relation", "") - if relation in primary or (allow_structure and relation in secondary): + from graphify.qt_relationship_views import relationship_label + if relation in primary or relationship_label(edge) or (allow_structure and relation in secondary): selected.append(edge) if selected: return selected @@ -1245,6 +1246,9 @@ def generate_call_table_rows( callers = defaultdict(set) callees = defaultdict(set) for e in (all_edges if all_edges is not None else section_edges): + from graphify.qt_relationship_views import is_event_relationship + if is_event_relationship(e): + continue src = e.get("source", "") tgt = e.get("target", "") if e.get("relation") in ("calls", "imports", "imports_from", "uses", "method", "indirect_call"): @@ -1778,6 +1782,9 @@ def write_callflow_html(
""") + from graphify.qt_relationship_views import render_relationship_table + html.append(render_relationship_table(nodes, edges, lang)) + # ── Section: Hyperedges (if any) ── if hyperedges: html.append("""

Group Relationships (Hyperedges)

diff --git a/graphify/cli.py b/graphify/cli.py index 5008b89c50..a57acc6356 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1679,6 +1679,8 @@ def dispatch_command(cmd: str) -> None: G = json_graph.node_link_graph(_raw, edges="links") except TypeError: G = json_graph.node_link_graph(_raw) + # Resolve exact IDs and qualified source::symbol names before scoring; + # the selected graph still retains Qt logical direction for traversal. src_nid, src_scored, src_err = _resolve_path_endpoint(G, source_label) tgt_nid, tgt_scored, tgt_err = _resolve_path_endpoint(G, target_label) for _label, _nid, _err in ( @@ -1760,6 +1762,7 @@ def dispatch_command(cmd: str) -> None: sys.exit(0) hops = len(path_nodes) - 1 segments = [] + path_edges = [] from graphify.build import edge_datas for i in range(len(path_nodes) - 1): u, v = path_nodes[i], path_nodes[i + 1] @@ -1779,6 +1782,7 @@ def dispatch_command(cmd: str) -> None: for d in edge_datas(G, a, b): (fwd if d.get("_src", a) == u else bwd).append(d) datas = fwd or bwd + path_edges.extend(datas) forward = bool(fwd) rels = sorted({d.get("relation") for d in datas if d.get("relation")}) rel = "/".join(rels) if rels else "related" @@ -1791,6 +1795,10 @@ def dispatch_command(cmd: str) -> None: else: segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}") print(f"Shortest path ({hops} hops):\n " + " ".join(segments)) + from graphify.path_provenance import path_provenance + evidence = path_provenance([G.nodes[nid] for nid in path_nodes], path_edges) + if evidence: + print(evidence.lstrip("\n")) from graphify import querylog querylog.log_query( kind="path", @@ -2488,6 +2496,7 @@ def _clear_html_stale_marker() -> None: elif cmd == "update": force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes") no_cluster = False + follow_symlinks = False args = sys.argv[2:] watch_arg: str | None = None for a in args: @@ -2497,6 +2506,9 @@ def _clear_html_stale_marker() -> None: if a == "--no-cluster": no_cluster = True continue + if a == "--follow-symlinks": + follow_symlinks = True + continue if a.startswith("-"): print(f"error: unknown update option: {a}", file=sys.stderr) sys.exit(2) @@ -2527,7 +2539,10 @@ def _clear_html_stale_marker() -> None: # Interactive CLI: block on the per-repo lock rather than skip, so the # user sees their explicit `graphify update` complete instead of # exiting silently when a hook-driven rebuild happens to be running. - ok = _rebuild_code(watch_path, force=force, no_cluster=no_cluster, block_on_lock=True) + # Discovery remains opt-in; the existing watch owner checks containment + # and publishes the same accepted graph/state cohort for either profile. + ok = _rebuild_code(watch_path, force=force, no_cluster=no_cluster, + follow_symlinks=follow_symlinks, block_on_lock=True) if ok: print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.") if not ( @@ -3732,6 +3747,26 @@ def _parse_float(name: str, raw: str) -> float: existing_graph_path, target, _seen_files, detection=detection ) + # QML scope resolution needs unchanged component/metadata facts too. + # Until dependency-directed invalidation is implemented, rebuild the + # live code corpus on QML (including deletion) or QML-JS input changes. + from graphify.qml_safety import ( + QmlSafetyError, qml_refresh_required, require_complete_qml, + ) + from graphify.qt_analysis_state import inspect_qt_analysis, commit_qt_analysis + try: + qt_state = inspect_qt_analysis(target, graphify_out, files_by_type.get("code", []), + excludes=_effective_excludes or (), gitignore=_effective_gitignore) + except ValueError as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) + if incremental_mode and ((qt_state.has_qt and qt_state.changed) or qml_refresh_required( + files_by_type.get("code", []), + [*code_files, *deleted_files, *excluded_files, *graph_stale_sources], + )): + code_files = [Path(p) for p in files_by_type.get("code", [])] + print("[graphify extract] Qt/QML inputs changed; refreshing the live code corpus.") + semantic_files = doc_files + paper_files + image_files # --code-only: index code (pure local AST, no key) and skip the semantic # (doc/paper/image) pass entirely, so a mixed repo doesn't hard-fail when no @@ -3934,7 +3969,7 @@ def _parse_float(name: str, raw: str) -> float: # graphify-out/ dir into a project that asked for external output. # `root` stays the scanned project so source_file/ids relativize # against it; conflating the two basenamed every node (#1941). - ast_kwargs: dict = {"cache_root": out_root, "root": target} + ast_kwargs: dict = {"cache_root": out_root, "root": target, "qml_import_roots": qt_state.import_roots, "refresh_native": qt_state.has_qt} if cli_max_workers is not None: ast_kwargs["max_workers"] = cli_max_workers # #2437/#2438 (the `graphify update` twin of watch's #2406 fix): an @@ -4088,6 +4123,14 @@ def _ctx_identity(source_file) -> str | None: sys.exit(1) ast_result = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0} _extraction_incomplete = True # the whole AST pass was lost + # An incomplete QML scope cannot replace a prior contribution even + # when total node counts increase or a force/partial flag is used. + # Reject before semantic work, graph reconciliation, or publication. + try: + require_complete_qml(ast_result, code_files, operation="extract", root=target) + except QmlSafetyError as exc: + print(f"[graphify extract] error: {exc}", file=sys.stderr) + sys.exit(1) stages.mark("AST extract") # Semantic extraction on docs/papers/images. Check cache first. @@ -4451,6 +4494,8 @@ def _invalidate_file_manifest_for_db_graph() -> None: ) try: _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target, scan_corpus=_scan_corpus, clear_semantic=_cleared_semantic, clear_ast=_cleared_ast or None) + if not _cleared_ast: + commit_qt_analysis(graphify_out, qt_state) except Exception as exc: print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr) stages.total() @@ -4574,6 +4619,8 @@ def _invalidate_file_manifest_for_db_graph() -> None: try: if has_path: _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target, scan_corpus=_scan_corpus, clear_semantic=_cleared_semantic, clear_ast=_cleared_ast or None) + if not _cleared_ast: + commit_qt_analysis(graphify_out, qt_state) except Exception as exc: print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr) if global_merge: @@ -4786,6 +4833,8 @@ def _invalidate_file_manifest_for_db_graph() -> None: try: if has_path: _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target, scan_corpus=_scan_corpus, clear_semantic=_cleared_semantic, clear_ast=_cleared_ast or None) + if not _cleared_ast: + commit_qt_analysis(graphify_out, qt_state) except Exception as exc: print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr) diff --git a/graphify/cpp_header.py b/graphify/cpp_header.py new file mode 100644 index 0000000000..1b18e9b2b4 --- /dev/null +++ b/graphify/cpp_header.py @@ -0,0 +1,69 @@ +"""Bounded lexical C++ header admission without importing optional parser bindings.""" +from __future__ import annotations + +from pathlib import Path +import re + +_DECLARATION = re.compile( + rb"\b(?:class|namespace)\s+[A-Za-z_]\w*|\btemplate\s*<|::|" + rb"\b(?:public|private|protected)\s*:|\b(?:struct|union)\s+[A-Za-z_]\w*\s+final\b" +) +_NUMBER = re.compile(rb"(?:[0-9]|\.[0-9])(?:[A-Za-z0-9_.]|'[A-Za-z0-9_])*") +_RAW = re.compile(rb'(?:u8|u|U|L)?R"([^\s()\\]{0,16})\(') + + +def _code(source: bytes) -> bytes: + """Hide inert tokens while preserving separation, continuation and literal bounds.""" + result, offset = bytearray(source), 0 + while offset < len(source): + number = _NUMBER.match(source, offset) + if number: + offset = number.end() + continue + end = offset + if source[offset:offset + 2] == b"/*": + close = source.find(b"*/", offset + 2) + end = len(source) if close < 0 else close + 2 + elif source[offset:offset + 2] == b"//": + end = offset + 2 + while end < len(source): + if (source[end:end + 1] == b"\n" + and source[end - 1:end] != b"\\" and source[end - 2:end] != b"\\\r"): + break + end += 1 + else: + raw = _RAW.match(source, offset) + if raw: + close = source.find(b")" + raw[1] + b'"', raw.end()) + end = len(source) if close < 0 else close + len(raw[1]) + 2 + elif source[offset:offset + 1] in {b'"', b"'"}: + quote, end = source[offset], offset + 1 + while end < len(source): + if source[end] == 92: + end += 2 + elif source[end] == quote: + end += 1 + break + else: + end += 1 + if end > offset: + end = min(end, len(source)) + result[offset:end] = b" " * (end - offset) + offset = end + else: + offset += 1 + return bytes(result) + + +def is_cpp_header(path: Path) -> bool: + """Recognize supported visible declarations; uncertain headers keep C dispatch. + + This admission hint supplies no type/member or SDK authority. Objective-C + selection remains owned by the facade and precedes this C++ check. + """ + try: + with path.open("rb") as stream: + source = stream.read(256 * 1024) + except OSError: + return False + return _DECLARATION.search(_code(source)) is not None diff --git a/graphify/detect.py b/graphify/detect.py index 52927f9f9c..8286369a79 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -46,6 +46,8 @@ class FileType(str, Enum): CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.vb', '.cbl', '.cob', '.cobol', '.cpy', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.vh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.robot', '.resource', '.sol', '.erl', '.hrl', '.escript'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'} +# Qt metadata is source input for bounded deterministic readers, not semantic prose. +CODE_EXTENSIONS.update({'.qml', '.qmltypes', '.cmake', '.pro', '.pri', '.qrc'}) PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} OFFICE_EXTENSIONS = {'.docx', '.xlsx'} @@ -520,6 +522,8 @@ def classify_file(path: Path) -> FileType | None: # document path — otherwise apm.yml (a .yml "document") would be LLM-extracted # and a package would split into duplicate file-anchored nodes (#1377). from graphify.manifest_ingest import is_package_manifest_path + if path.name in {"qmldir", "CMakeLists.txt"}: + return FileType.CODE if is_package_manifest_path(path): return FileType.CODE # Compound extensions must be checked before simple suffix lookup diff --git a/graphify/export.py b/graphify/export.py index c1abfcf938..0be3a75118 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -370,19 +370,24 @@ def _json_sort_key(item: dict) -> str: if isinstance(link, dict) and link.get("source") in node_ids and link.get("target") in node_ids ] + # Validate preserved parallel links through the same Qt direction boundary. + from graphify.qt_export import logical_endpoints for link in data["links"]: if "confidence_score" not in link: conf = link.get("confidence", "EXTRACTED") link["confidence_score"] = _CONFIDENCE_SCORE_DEFAULTS.get(conf, 1.0) - # Restore original edge direction. Undirected NetworkX storage may - # canonicalize endpoint order, flipping `calls` and other directional - # edges in graph.json. The build path stashes the true endpoints in - # _src/_tgt for exactly this purpose (#563). - true_src = link.pop("_src", None) - true_tgt = link.pop("_tgt", None) - if true_src is not None and true_tgt is not None: - link["source"] = true_src - link["target"] = true_tgt + # Undirected storage may reverse accepted endpoints (#563). Validate + # the complete marker pair before consuming it; partial/foreign markers + # cannot become new serialized endpoints or disappear into legacy data. + try: + source, target = logical_endpoints(link["source"], link["target"], link, directed=G.is_directed()) + except ValueError: + print("[graphify] error: QT_EXPORT_DIRECTION: invalid logical edge pair; JSON was not written. " + "Re-extract or repair the graph and retry.", file=sys.stderr) + return False + link.pop("_src", None) + link.pop("_tgt", None) + link["source"], link["target"] = source, target # Canonicalize the key order WITHIN each node/link dict. node_link_data always # appends the node key (`id`) at the end, so a node whose `id` was an inline # attribute on a cold build (position varies) lands last after a read-rebuild @@ -506,6 +511,13 @@ def _cypher_label(raw: str, fallback: str) -> str: def to_cypher(G: nx.Graph, output_path: str) -> None: + # Qt/QML facts retain occurrence identities, logical direction and complete + # source contracts. Ordinary graphs continue through the unchanged writer. + from graphify.qt_export import qt_cypher_lines + qt_lines = qt_cypher_lines(G) + if qt_lines is not None: + write_text_atomic(output_path, "\n".join(qt_lines) + "\n") + return lines = ["// Neo4j Cypher import - generated by /graphify", ""] for node_id, data in G.nodes(data=True): label = _cypher_escape(data.get("label", node_id)) diff --git a/graphify/exporters/graphdb.py b/graphify/exporters/graphdb.py index d0bcc4f61b..ad2370e69e 100644 --- a/graphify/exporters/graphdb.py +++ b/graphify/exporters/graphdb.py @@ -4,6 +4,7 @@ from graphify.analyze import _node_community_map import networkx as nx import re +from graphify.qt_export import edge_properties, export_properties, has_qt_qml, logical_endpoints, preflight_qt_payload def _distinct_node_labels(G: nx.Graph) -> list[str]: @@ -41,6 +42,9 @@ def push_to_neo4j( ) from e node_community = _node_community_map(communities) if communities else {} + qt_payload = has_qt_qml(G) + if qt_payload: + preflight_qt_payload(G) def _safe_rel(relation: str) -> str: return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO" @@ -66,10 +70,7 @@ def _safe_label(label: str) -> str: pass for node_id, data in G.nodes(data=True): - props = { - k: v for k, v in data.items() - if isinstance(v, (str, int, float, bool)) and not k.startswith("_") - } + props = export_properties(data, preserve_metadata=qt_payload) props["id"] = node_id cid = node_community.get(node_id) if cid is not None: @@ -84,16 +85,17 @@ def _safe_label(label: str) -> str: for u, v, data in G.edges(data=True): rel = _safe_rel(data.get("relation", "RELATED_TO")) - props = { - k: v for k, v in data.items() - if isinstance(v, (str, int, float, bool)) and not k.startswith("_") - } + src, tgt = logical_endpoints(u, v, data) if qt_payload else (u, v) + props = edge_properties(src, tgt, data, preserve_metadata=qt_payload) + identity = " {graphify_fact_key: $fact_key}" if qt_payload else "" + key_args = {"fact_key": props["graphify_fact_key"]} if qt_payload else {} session.run( f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) " - f"MERGE (a)-[r:{rel}]->(b) SET r += $props", - src=u, - tgt=v, + f"MERGE (a)-[r:{rel}{identity}]->(b) SET r += $props", + src=src, + tgt=tgt, props=props, + **key_args, ) edges_pushed += 1 @@ -138,6 +140,9 @@ def push_to_falkordb( from urllib.parse import urlparse node_community = _node_community_map(communities) if communities else {} + qt_payload = has_qt_qml(G) + if qt_payload: + preflight_qt_payload(G) def _safe_rel(relation: str) -> str: return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO" @@ -179,10 +184,7 @@ def _safe_label(label: str) -> str: pass for node_id, data in G.nodes(data=True): - props = { - k: v for k, v in data.items() - if isinstance(v, (str, int, float, bool)) and not k.startswith("_") - } + props = export_properties(data, preserve_metadata=qt_payload) props["id"] = node_id cid = node_community.get(node_id) if cid is not None: @@ -196,14 +198,14 @@ def _safe_label(label: str) -> str: for u, v, data in G.edges(data=True): rel = _safe_rel(data.get("relation", "RELATED_TO")) - props = { - k: v for k, v in data.items() - if isinstance(v, (str, int, float, bool)) and not k.startswith("_") - } + src, tgt = logical_endpoints(u, v, data) if qt_payload else (u, v) + props = edge_properties(src, tgt, data, preserve_metadata=qt_payload) + identity = " {graphify_fact_key: $fact_key}" if qt_payload else "" + key_args = {"fact_key": props["graphify_fact_key"]} if qt_payload else {} graph.query( f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) " - f"MERGE (a)-[r:{rel}]->(b) SET r += $props", - {"src": u, "tgt": v, "props": props}, + f"MERGE (a)-[r:{rel}{identity}]->(b) SET r += $props", + {"src": src, "tgt": tgt, "props": props, **key_args}, ) edges_pushed += 1 diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 14f7812499..573e93239e 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -9,6 +9,8 @@ import json import networkx as nx from graphify.security import sanitize_label +from graphify.qt_html import edge_fields, edge_title, semantic_fields, semantic_title +from graphify.qt_export import has_qt_qml MAX_NODES_FOR_VIZ = 5_000 @@ -149,6 +151,7 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str: // Build vis datasets const nodesDS = new vis.DataSet(RAW_NODES.map((n, i) => ({{ + ...n, id: n.id, label: n.label, color: n.color, size: n.size, font: n.font, title: n.title, // Fermat/golden-angle spiral seed positions (#3699): spreading nodes out @@ -160,9 +163,12 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str: community: n.community, community_name: n.community_name, source_file: n.source_file, file_type: n.file_type, degree: n.degree, member_count: n.member_count, + qt_qml: n.qt_qml, + source_location: n.source_location, }}))); const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{ + ...e, id: i, from: e.from, to: e.to, label: '', title: e.title, @@ -219,8 +225,25 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str: html += `
Community: ${{esc(n.community_name || '')}}
`; if (!isMetaNode) {{ html += `
Source: ${{esc(n.source_file || '-')}}
`; + if (n.qt_qml && typeof n.qt_qml === 'object' && !Array.isArray(n.qt_qml)) {{ + // Only the bounded public projection is displayed; opaque transport and + // provider data remain in RAW_NODES for faithful source-fact retention. + html += `
Location: ${{esc(n.source_location || '-')}}
`; + Object.entries(n.qt_qml).forEach(([contract, fields]) => {{ + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return; + Object.entries(fields).forEach(([key, value]) => {{ + html += `
${{esc(contract.toUpperCase())}} ${{esc(key.replace(/_/g, ' '))}}: ${{esc(Array.isArray(value) ? value.join(', ') : value)}}
`; + }}); + }}); + }} }} else if (n.member_count !== undefined && n.member_count !== null) {{ html += `
Members: ${{esc(n.member_count)}}
`; + // Membership links need source-edge counts; aggregate degree alone cannot + // describe internal relationships. Caller-supplied counts may be unknown. + const internalKnown = Number.isInteger(n.internal_source_edges); + const externalKnown = Number.isInteger(n.external_source_edges); + html += `
Internal source edges: ${{esc(internalKnown ? n.internal_source_edges : 'unavailable')}}
`; + html += `
External source edges: ${{esc(externalKnown ? n.external_source_edges : 'unavailable')}}
`; }} html += `
Degree: ${{n.degree}}
`; if (neighborIds.length) {{ @@ -278,7 +301,8 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str: const q = searchInput.value.toLowerCase().trim(); searchResults.innerHTML = ''; if (!q) {{ searchResults.style.display = 'none'; return; }} - const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20); + const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q) || + JSON.stringify(n.qt_qml || '').toLowerCase().includes(q)).slice(0, 20); if (!matches.length) {{ searchResults.style.display = 'none'; return; }} searchResults.style.display = 'block'; matches.forEach(n => {{ @@ -443,11 +467,23 @@ def to_html( meta = _nx.Graph() for cid, members in communities.items(): meta.add_node(str(cid), label=(community_labels or {}).get(cid, f"Community {cid}")) - edge_counts = _Counter() + edge_counts, internal_counts, external_counts = _Counter(), _Counter(), _Counter() for u, v in G.edges(): cu, cv = node_to_community.get(u), node_to_community.get(v) - if cu is not None and cv is not None and cu != cv: + if cu is None or cv is None: + continue + # Each source edge counts once, including parallel/directed + # edges and loops. Boundary edges belong to both communities; + # internal membership never invents an aggregate self-link. + if cu == cv: + internal_counts[cu] += 1 + else: + external_counts[cu] += 1 + external_counts[cv] += 1 edge_counts[(min(cu, cv), max(cu, cv))] += 1 + for cid in communities: + meta.nodes[str(cid)].update(internal_source_edges=internal_counts[cid], + external_source_edges=external_counts[cid]) for (cu, cv), w in edge_counts.items(): meta.add_edge(str(cu), str(cv), weight=w, relation=f"{w} cross-community edges", confidence="AGGREGATED") @@ -480,6 +516,10 @@ def to_html( "nodes": comm_ids, }) meta.graph["hyperedges"] = remapped + # Community aggregation drops occurrence-level evidence. State that + # omission in the actual artifact instead of implying full Qt detail. + if has_qt_qml(G): + meta.graph["qt_qml_source_details_omitted"] = True written = to_html(meta, meta_communities, output_path, community_labels=community_labels, member_counts=mc) if not written: @@ -552,6 +592,16 @@ def to_html( } if member_counts: node["member_count"] = member_counts.get(cid, len(communities.get(cid, []))) + if not data.get("file_type") and not data.get("source_file"): + # Only nonnegative integer source counts may become inspector + # facts; booleans, malformed strings and missing data stay unknown. + for field in ("internal_source_edges", "external_source_edges"): + value = data.get(field) + if type(value) is int and value >= 0: + node[field] = value + # Project bounded human-readable Qt/QML fields while retaining the + # complete source contract and user attributes, without editing G. + node.update(semantic_fields(data)) # Conditional learning fields — only present for annotated nodes, so # un-annotated output keeps the exact pre-feature node dict shape. entry = learning_overlay.get(str(node_id)) if learning_overlay else None @@ -582,6 +632,7 @@ def to_html( if stale: lesson += " [code changed — re-verify]" node["title"] = f"{label}\n{sanitize_label(lesson)}" + node["title"] += semantic_title(node) vis_nodes.append(node) # Build edges list. Restore original edge direction from _src/_tgt @@ -598,11 +649,12 @@ def to_html( "from": true_src, "to": true_tgt, "label": relation, - "title": sanitize_label(f"{relation} [{confidence}]"), + "title": sanitize_label(f"{relation} [{confidence}]") + edge_title(data), "dashes": confidence != "EXTRACTED", "width": 2 if confidence == "EXTRACTED" else 1, "color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35}, "confidence": confidence, + **edge_fields(data), }) # Build community legend data @@ -623,6 +675,8 @@ def _js_safe(obj) -> str: hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", [])) title = _html.escape(sanitize_label(_html_document_title(output_path))) stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities" + if G.graph.get("qt_qml_source_details_omitted"): + stats += "
Qt/QML source details are omitted in this community view." html = f""" diff --git a/graphify/extract.py b/graphify/extract.py index b463ae3887..a498b4e916 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -15,6 +15,7 @@ from typing import Any, Callable from .cache import load_cached, save_cached +from .source_identity import normalize_input_source, resolved_source_owners, walked_relative_source from .mcp_ingest import extract_mcp_config, is_mcp_config_path from .manifest_ingest import extract_package_manifest, is_package_manifest_path from .resolver_registry import ( @@ -57,6 +58,14 @@ from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401 from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 from graphify.extractors.r import extract_r, resolve_r_sourced_calls # noqa: F401 +from graphify.extractors.qml import extract_qml # noqa: F401 +from graphify.extractors.qml_metadata import extract_qmldir # noqa: F401 +from graphify.extractors.qml_cmake import extract_cmake # noqa: F401 +from graphify.extractors.qml_qmake import extract_qmake # noqa: F401 +from graphify.extractors.qml_resources import extract_qrc # noqa: F401 +from graphify.extractors.qml_types import extract_qmltypes # noqa: F401 +from graphify.qml_safety import is_qml_path +from graphify.qt_incremental import qt_syntax_cache_bypass, requires_native_refresh from graphify.extractors.razor import extract_razor # noqa: F401 from graphify.extractors.robot import extract_robot # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 @@ -189,7 +198,8 @@ def _safe_extract( extractor: Callable, path: Path, *, scan_root: Path | None = None ) -> dict: try: - if extractor is extract_python: + if extractor in (extract_python, extract_qml, extract_qmldir, + extract_cmake, extract_qmake, extract_qrc, extract_qmltypes): return extractor(path, root=scan_root) return extractor(path) except RecursionError: @@ -2974,9 +2984,10 @@ def extract_cpp(path: Path) -> dict: except OSError: # Let _extract_generic report the read failure in its usual shape. return _augment_cpp_string_tests(path, _extract_generic(path, _CPP_CONFIG)) - result = _extract_generic( - path, _CPP_CONFIG, source_override=_normalize_cpp_cli(source) or source - ) + from graphify.extractors.qt_cpp_syntax import normalize_qt_cpp + normalized = _normalize_cpp_cli(source) or source + normalized = normalize_qt_cpp(normalized) or normalized + result = _extract_generic(path, _CPP_CONFIG, source_override=normalized) return _augment_cpp_string_tests(path, result) @@ -3134,6 +3145,10 @@ def _canonicalize_csharp_namespace_nodes(all_nodes: list[dict], all_edges: list[ for node in all_nodes: if node.get("type") != "namespace": continue + # Qt/QML namespace-shaped facts represent distinct source declarations + # and occurrences. Equal labels cannot merge providers or resource aliases. + if node.get("metadata", {}).get("qml", {}).get("contract_version") == 1: + continue label = node.get("label") if isinstance(label, str): by_label.setdefault(label, []).append(node) @@ -3200,6 +3215,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool: # Extensions absent from this map (docs, configs, unknown languages) resolve to # no family and are never filtered — same permissive default as before. _LANG_FAMILY_BY_EXT: dict[str, str] = { + ".qml": "qml", # JS/TS module graph (SFCs embed JS/TS) ".js": "jsts", ".jsx": "jsts", ".mjs": "jsts", ".cjs": "jsts", ".ts": "jsts", ".tsx": "jsts", ".mts": "jsts", ".cts": "jsts", @@ -6840,6 +6856,12 @@ def add_existing_edge(edge: dict) -> None: _DISPATCH: dict[str, Any] = { + ".qml": extract_qml, + ".qmltypes": extract_qmltypes, + ".cmake": extract_cmake, + ".pro": extract_qmake, + ".pri": extract_qmake, + ".qrc": extract_qrc, ".py": extract_python, ".js": extract_js, ".jsx": extract_js, @@ -6961,6 +6983,7 @@ def add_existing_edge(edge: dict) -> None: # rather than falling back like Pascal does. Used by the #1745 warning in # extract() to tell the user which extra restores the language. _EXTRA_FOR_EXTENSION = { + ".qml": "qml", ".vb": "vbnet", ".r": "r", ".sol": "solidity", @@ -7046,20 +7069,6 @@ def _is_objc_header(path: Path) -> bool: return any(marker in head for marker in _OBJC_HEADER_MARKERS) -# C++-only signals. None of these are valid in a plain C header, so finding one -# in a `.h` is a high-confidence signal the header is C++ (#1547). The C grammar -# has no class_specifier, so a `class Foo { ... };` header routed to extract_c -# loses the class and its method prototypes (a junk `foo_foo` node + a sourceless -# `class` stub); routing to extract_cpp recovers the real type. Kept CONSERVATIVE: -# a plain C header with none of these stays on extract_c. ObjC sniffing keeps -# priority (an ObjC header can legitimately contain `::`/`class` inside an inline -# C++ block when compiled as Objective-C++). -_CPP_HEADER_MARKERS = ( - b"class ", b"namespace ", b"template", b"::", - b"public:", b"private:", b"protected:", -) - - def _is_objc_source(path: Path) -> bool: """Whether a `.m` file is Objective-C rather than MATLAB/Octave (#1702). @@ -7077,20 +7086,22 @@ def _is_objc_source(path: Path) -> bool: def _is_cpp_header(path: Path) -> bool: """Whether a `.h` file is C++ rather than plain C (#1547). - Mirrors `_is_objc_header`: sniffs for a C++-only token. Used only to reroute - a `.h` from extract_c to extract_cpp when no ObjC marker is present (ObjC has - priority). Conservative by construction — a plain C header matches nothing - here and keeps its existing extract_c routing. + Visible declaration tokens in the bounded header prefix select C++, across + whitespace and comments between tokens. Inert literals/comments and + inconclusive headers retain C. Objective-C selection has priority. """ - try: - head = path.read_bytes()[:256 * 1024] - except OSError: - return False - return any(marker in head for marker in _CPP_HEADER_MARKERS) + # Admission uses visible tokens across whitespace; strings/comments cannot + # select another parser. This standard-library helper needs no Qt parser. + from graphify.cpp_header import is_cpp_header + return is_cpp_header(path) def _get_extractor(path: Path) -> Any | None: """Return the correct extractor function for a file, or None if unsupported.""" + if path.name == "qmldir": + return extract_qmldir + if path.name == "CMakeLists.txt": + return extract_cmake if path.name.lower().endswith(".blade.php"): return extract_blade # MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed @@ -7160,8 +7171,9 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: Returns: (index, result_dict) so results can be placed back in order. """ - if len(args) == 4: - idx, path_str, root_str, cache_location_str = args + native_cache_bypass = bool(args[4]) if len(args) == 5 else False + if len(args) in (4, 5): + idx, path_str, root_str, cache_location_str = args[:4] else: # legacy 3-tuple: location == anchor idx, path_str, root_str = args cache_location_str = root_str @@ -7169,7 +7181,7 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: root = Path(root_str) cache_location = Path(cache_location_str) _raise_recursion_limit() - bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES + bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES or qt_syntax_cache_bypass(path, native=native_cache_bypass) # Check cache first (avoid re-extraction) if not bypass_cache: @@ -7229,6 +7241,7 @@ def _extract_parallel( max_workers: int | None, total_files: int, cache_location: Path | None = None, + native_cache_bypass: bool = False, ) -> bool: """Extract uncached files in parallel using ProcessPoolExecutor. @@ -7278,7 +7291,8 @@ def _extract_parallel( # the cache dir is written (defaults to root when not decoupled) (#1774). root_str = str(root) cache_loc_str = str(cache_location if cache_location is not None else root) - work_items = [(idx, str(path), root_str, cache_loc_str) for idx, path in uncached_work] + work_items = [(idx, str(path), root_str, cache_loc_str, True) if native_cache_bypass + else (idx, str(path), root_str, cache_loc_str) for idx, path in uncached_work] done_count = 0 failed: list[int] = [] # positions into uncached_work whose future failed @@ -7371,6 +7385,7 @@ def _extract_sequential( root: Path, total_files: int, cache_location: Path | None = None, + native_cache_bypass: bool = False, ) -> None: """Extract uncached files sequentially (fallback for small batches).""" _PROGRESS_INTERVAL = 100 @@ -7388,7 +7403,7 @@ def _extract_sequential( if extractor is None: per_file[idx] = {"nodes": [], "edges": []} continue - bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES + bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES or qt_syntax_cache_bypass(path, native=native_cache_bypass) # XAML boundary anchors on `root` (the corpus), not the cache location. result = _safe_extract_with_xaml_root(extractor, path, root) # See _extract_single_file: don't cache an anomalous zero-node result (#1666), @@ -7414,6 +7429,8 @@ def extract( max_workers: int | None = None, resolution_context_nodes: list[dict] | None = None, resolution_context_edges: list[dict] | None = None, + qml_import_roots: tuple[str, ...] | None = None, + refresh_native: bool = False, ) -> dict: """Extract AST nodes and edges from a list of code files. @@ -7454,8 +7471,10 @@ def extract( resolution_context_nodes: they widen the resolvers' view but only fresh results are appended to the returned nodes/edges. """ - paths = [Path(p) for p in paths] anchor_root = Path(root) if root is not None else None + # Normalize native entry spellings before facts, worker dispatch or cache keys. + context_root = anchor_root if anchor_root is not None else cache_root + paths = [normalize_input_source(Path(p), context_root) for p in paths] _check_tree_sitter_version() _raise_recursion_limit() # Workspace package manifests/globs can change during watch or repeated extraction. @@ -7505,6 +7524,12 @@ def extract( elif cache_root is not None: root = cache_root root = root.resolve() + # One source-name decision serves IDs and provenance; realpath state remains + # owned by this analysis run, with no second cache or discovery authority. + def _walked_rel(path, resolved=None): + walked = Path(os.path.abspath(path)) + physical = resolved if resolved is not None else _cached_realpath(str(walked), os.getcwd()) + return walked_relative_source(walked, root, physical, realpath=_cached_realpath, cwd=os.getcwd()) python_scan_paths = list(paths) for context_node in resolution_context_nodes or []: source_file = context_node.get("source_file") @@ -7533,6 +7558,8 @@ def extract( cache_location = (cache_root if cache_root is not None else Path(".")).resolve() total = len(paths) + native_cache_bypass = refresh_native or requires_native_refresh(paths) + native_policy_kwargs = {"native_cache_bypass": True} if native_cache_bypass else {} # Phase 1: separate cached hits from uncached work per_file: list[dict | None] = [None] * total uncached_work: list[tuple[int, Path]] = [] @@ -7541,7 +7568,7 @@ def extract( if _get_extractor(path) is None: per_file[i] = {"nodes": [], "edges": []} continue - bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES + bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES or qt_syntax_cache_bypass(path, native=native_cache_bypass) if not bypass_cache: cached = load_cached(path, root, cache_root=cache_location) if cached is not None: @@ -7574,7 +7601,7 @@ def extract( ran_parallel = False if parallel and len(uncached_work) >= _PARALLEL_THRESHOLD: ran_parallel = _extract_parallel( - uncached_work, per_file, root, max_workers, total, cache_location + uncached_work, per_file, root, max_workers, total, cache_location, **native_policy_kwargs ) if not ran_parallel: # #2444: only re-extract what the pool didn't finish. A pool that @@ -7582,7 +7609,7 @@ def extract( # the whole batch would throw that work away. _extract_sequential( [(i, p) for (i, p) in uncached_work if per_file[i] is None], - per_file, root, total, cache_location, + per_file, root, total, cache_location, **native_policy_kwargs, ) # Fill any remaining None slots. With the #2444/#2445 handling above this @@ -7596,6 +7623,11 @@ def extract( "error": "internal: no extraction result produced", } + # Overlay only already-admitted JavaScript resources referenced by QML. + # Its facts are separate from the generic JS graph and never execute source. + from graphify.extractors.qml_scripts import collect_qml_scripts + collect_qml_scripts(paths, per_file, root=root) + # #1666: surface any source file an extractor accepted but that produced zero # nodes (not even a file node). Such a file is silently absent from the graph, # so affected/explain are blind to and through it with no other signal. @@ -7839,7 +7871,7 @@ def _describe_syntax_error(rel: str, line: "int | None", kept: int) -> str: # still carry the raw file-stem prefix; the per-file prefix remap then diverges # them (foo_h vs foo_cpp), so the collapse must happen first. Collapsing here # also means disambiguation sees one source_file per id and won't split them. - _merge_decl_def_classes(all_nodes, all_edges) + _merge_decl_def_classes(all_nodes, all_edges, all_raw_calls) # Remap file node IDs from absolute-path-derived to the canonical # {parent_dir}_{stem} spec form so (a) graph.json edge endpoints are stable @@ -7877,7 +7909,7 @@ def _portable_out_of_root_sf(p: Path) -> str: # file node is correctly relativized to main and the skill.md spec wants # main_run -- splitting the symbol into AST/semantic ghosts (#1096). Relativize # the symbol prefix the same way, gated by source_file so two files sharing a - # prefix can't cross-contaminate. Keyed by resolved path -> (old_pref, new_pref). + # prefix can't cross-contaminate. Keyed by walked root-relative source identity. # Each file maps from up to TWO old prefixes — the input-form prefix # _file_node_id(path) and the absolute-resolved-form prefix # _file_node_id(path.resolve()). Alias/workspace imports resolve specifiers @@ -7919,9 +7951,9 @@ def _portable_out_of_root_sf(p: Path) -> str: # Already covered: either the target is in this batch (its input # form is the same form the extractors minted ids from, and the # per-path loop registers both that and the resolved form) or an - # earlier stamped edge registered it. Re-appending it here would - # re-run its per-path iteration AFTER later batch files and could - # flip the last-writer of a colliding old-id key. + # earlier stamped edge registered it. The ownership pass below + # handles shared forms independently; no redundant target entry + # should participate in its accepted source claims. continue _remap_seen.add(_tp) try: @@ -7977,15 +8009,15 @@ def _portable_out_of_root_sf(p: Path) -> str: if _raw_tp != _tp: _remap_seen.add(_raw_tp) remap_paths.append(_raw_tp) + # Shared physical forms have one explicit owner, independent of batch order. + _physical_owners = resolved_source_owners( + remap_paths, root, walked_relative=_walked_rel, realpath=_cached_realpath, cwd=os.getcwd()) for path in remap_paths: old_id = _make_id(str(path)) try: - rel = path.relative_to(root) + rel = _walked_rel(path) except ValueError: - try: - rel = path.resolve().relative_to(root) - except ValueError: - continue + continue new_id = _file_node_id(rel) if old_id != new_id: id_remap[old_id] = new_id @@ -7993,8 +8025,9 @@ def _portable_out_of_root_sf(p: Path) -> str: # alias/workspace import targets (resolved via .resolve()) remap to # canonical instead of orphaning (#1529). old_id_abs = _make_id(str(path.resolve())) - if old_id_abs != new_id: - id_remap[old_id_abs] = new_id + physical_id = _file_node_id(_physical_owners[path.resolve()]) + if old_id_abs != physical_id: + id_remap[old_id_abs] = physical_id old_prefs: list[tuple[str, str]] = [] old_pref = _file_node_id(path) if old_pref != new_id: @@ -8010,18 +8043,19 @@ def _portable_out_of_root_sf(p: Path) -> str: # (#2243). Register the suffixed forms, preserving the extension tail # exactly as the prefix remap yields for in-batch entry nodes # (`c.sh` -> `c_sh__entry`): new prefix + the tail of the minted id. - for _old, _pref in ((old_id, old_pref), (old_id_abs, old_pref_abs)): + for _old, _pref, _owner in ((old_id, old_pref, new_id), + (old_id_abs, old_pref_abs, physical_id)): if not _old.startswith(_pref): continue - _entry_new = new_id + _old[len(_pref):] + "__entry" + _entry_new = _owner + _old[len(_pref):] + "__entry" _entry_old = _old + "__entry" if _entry_old != _entry_new: id_remap.setdefault(_entry_old, _entry_new) if old_prefs: - prefix_remap[path.resolve()] = old_prefs + prefix_remap[root / rel] = old_prefs # Absolute form first: it is the longest, so prefix decomposition can # try forms in order without a shorter form shadowing it. - stem_forms[path.resolve()] = ( + stem_forms[root / rel] = ( new_id, [old_pref_abs, old_pref, new_id] ) if id_remap: @@ -8073,7 +8107,7 @@ def _portable_out_of_root_sf(p: Path) -> str: if n.get("type") == "package": continue try: - entry = prefix_remap.get(Path(sf).resolve()) + entry = prefix_remap.get(root / _walked_rel(Path(sf))) except Exception: continue if entry is None: @@ -8179,8 +8213,8 @@ def _edge_key(edge: dict) -> str: def _decompose(target: str, tf: str) -> "tuple[str, str] | None": try: - forms = stem_forms.get(Path(tf).resolve()) - except (OSError, RuntimeError): + forms = stem_forms.get(root / _walked_rel(Path(tf))) + except (OSError, RuntimeError, ValueError): return None if not forms: return None @@ -8424,6 +8458,11 @@ def _looks_like_bash(result: object) -> bool: for n in resolution_nodes: if n.get("file_type") == "rationale" or n.get("type") == "namespace": continue + # Dedicated QML scopes resolve these facts; shared label guessing must + # not bind generic language calls to QML declarations or source sites. + _qml_metadata = n.get("metadata", {}).get("qml", {}) + if _qml_metadata.get("contract_version") == 1: + continue raw = n.get("label", "") normalised = raw.strip("()").lstrip(".") if normalised: @@ -8895,24 +8934,25 @@ def _sf_entry(sf: str, sf_path: Path) -> tuple[str, str, tuple[str, ...]]: cached = _sf_forms.get(sf) if cached is not None: return cached + # Accepted aliases identify the same physical source as the canonical + # scan root. Decide containment before the external-path fallback so a + # nearby short/symlink parent cannot turn native provenance into ../.. + # transport. Keep written and resolved forms for existing endpoint joins. try: - rel = sf_path.relative_to(root) + sf_resolved = _cached_realpath(str(sf_path), os.getcwd()) + except (OSError, RuntimeError): + sf_resolved = sf_path + try: + rel = _walked_rel(sf_path, sf_resolved) except ValueError: portable = _portable_out_of_root_sf(sf_path) canonical_id = _make_id("ext", portable) new_sf = portable else: - # In-root: the same canonical repo-relative form the real file - # node uses (_file_node_id), so the scan root can never leak into - # a persisted id. Real file nodes were already remapped by the - # #2169 pass, so only leftover absolute-derived ids match below - # (belt-and-braces for #2195 regex-rescue stubs and friends). + # IDs and source/definition provenance use the same physically + # admitted walked identity, including distinct discovered aliases. canonical_id = _file_node_id(rel) new_sf = rel.as_posix() - try: - sf_resolved = sf_path.resolve() - except (OSError, RuntimeError): - sf_resolved = sf_path # Learn the STEM (extension-dropped) forms too: symbol producers mint # compound ids as _make_id(_file_stem(path), name), so a node-less # absolute-derived endpoint arrives as _ and only @@ -9019,6 +9059,16 @@ def _canon(nid: str) -> str: e.pop("local_alias", None) e.pop("_python_plain_import", None) + # Native overlays must borrow final canonical C++ identities, not the + # earlier absolute/stem IDs. Context remains accepted, read-only input. + from graphify.qt_qml_pipeline import resolve_qt_qml + for failed in resolve_qt_qml(paths, per_file, all_nodes, all_edges, root=root, + import_roots=qml_import_roots, + context_nodes=resolution_context_nodes, + context_edges=resolution_context_edges): + if failed not in _failed_sources: + _failed_sources.append(failed) + # Tag AST provenance so the incremental watch rebuild can distinguish # AST-extracted nodes from semantic/LLM nodes. On a full re-extraction # the watcher drops any AST-marked node missing from the fresh output @@ -9068,6 +9118,12 @@ def _canon(nid: str) -> str: # manifest does not freeze them as processed (#2543). Callers that # only read nodes/edges ignore this key. "failed_sources": _failed_sources, + "qml_failures": [failure for result in per_file if result + for failure in result.get("qml_failures", [])], + "qt_failures": [failure for result in per_file if result + for failure in result.get("qt_failures", [])], + "diagnostics": [diagnostic for result in per_file if result + for diagnostic in result.get("diagnostics", [])], # Surfaces the actual dispatched source paths so build_merge / # merge_raw_extraction know which files were genuinely re-extracted # rather than guessing ownership from node["source_file"] (#3411). @@ -9079,6 +9135,12 @@ def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | N containment_root = root if root is not None else target from graphify.detect import _resolves_under_root if target.is_file(): + if is_qml_path(target): + from graphify.detect import _is_ignored, _load_graphifyignore + ignore_root = root if root is not None else target.parent + patterns = _load_graphifyignore(ignore_root) + if patterns and _is_ignored(target, ignore_root, patterns): + return [] return [target] if _resolves_under_root(target, containment_root) else [] _EXTENSIONS = set(_DISPATCH.keys()) from graphify.detect import ( @@ -9116,7 +9178,12 @@ def _ignored(p: Path) -> bool: for fname in filenames: p = dp / fname suffix = p.suffix - if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and not _is_installed_graphify_file(p) and _resolves_under_root(p, containment_root): + # Exact-name Qt manifests share ordinary sources' corpus gate; + # installed Graphify copies never become application facts. + if ((suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS + or p.name in {"qmldir", "CMakeLists.txt"}) + and not _ignored(p) and not _is_installed_graphify_file(p) + and _resolves_under_root(p, containment_root)): results.append(p) return sorted(results) # Walk with symlink following + cycle detection @@ -9137,7 +9204,11 @@ def _ignored(p: Path) -> bool: for fname in filenames: p = dp / fname suffix = p.suffix - if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and not _is_installed_graphify_file(p) and _resolves_under_root(p, containment_root): + # Following a contained alias preserves the same admission gate. + if ((suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS + or p.name in {"qmldir", "CMakeLists.txt"}) + and not _ignored(p) and not _is_installed_graphify_file(p) + and _resolves_under_root(p, containment_root)): results.append(p) return sorted(results) diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index e2c8afc627..d1ef54605f 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -29,6 +29,12 @@ from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest from graphify.extractors.r import extract_r +from graphify.extractors.qml import extract_qml +from graphify.extractors.qml_metadata import extract_qmldir +from graphify.extractors.qml_cmake import extract_cmake +from graphify.extractors.qml_qmake import extract_qmake +from graphify.extractors.qml_resources import extract_qrc +from graphify.extractors.qml_types import extract_qmltypes from graphify.extractors.razor import extract_razor from graphify.extractors.rust import extract_rust from graphify.extractors.sln import extract_sln @@ -40,6 +46,12 @@ from graphify.extractors.zig import extract_zig LANGUAGE_EXTRACTORS: dict[str, Callable[[Path], dict]] = { + "qml": extract_qml, + "qmldir": extract_qmldir, + "cmake": extract_cmake, + "qmake": extract_qmake, + "qrc": extract_qrc, + "qmltypes": extract_qmltypes, "apex": extract_apex, "bash": extract_bash, "blade": extract_blade, diff --git a/graphify/extractors/cpp_class_proof.py b/graphify/extractors/cpp_class_proof.py new file mode 100644 index 0000000000..2a8f0e604b --- /dev/null +++ b/graphify/extractors/cpp_class_proof.py @@ -0,0 +1,34 @@ +"""Accepted C++ class source proof guards mapping and sibling canonicalization.""" +from __future__ import annotations + +from graphify.extractors.cpp_constructors import _decoded, _fact, _span + + +def class_fact(node): + """Share the existing bounded transport/span/label contract, not a basename.""" + return _fact(node, "cpp_class") if node.get("_callable_class") is True else {} + + +def class_matches(node, qualified, syntax): + fact = class_fact(node) + if not fact or _decoded(fact["qualified_name_b64"]) != qualified: + return False + # A forward declaration denotes its exact qualified canonical class. A + # complete body's authority additionally requires its original byte span. + return syntax.child_by_field_name("body") is None or fact["span"] == _span(syntax) + + +def class_merge_allowed(group): + facts = [class_fact(node) for node in group] + if not any(isinstance(node.get("metadata"), dict) and "cpp_class" in node["metadata"] for node in group): + return True + if any(not fact or fact["ambiguous"] for fact in facts): + return False + return (len({_decoded(fact["qualified_name_b64"]) for fact in facts}) == 1 + and sum(fact["is_definition"] for fact in facts) <= 1) + + +def class_body_keeper(group, default): + """A forward header must not replace an implementation's complete-body span.""" + complete = [node for node in group if class_fact(node).get("is_definition") is True] + return complete[0] if len(complete) == 1 else default diff --git a/graphify/extractors/cpp_constructor_binding.py b/graphify/extractors/cpp_constructor_binding.py new file mode 100644 index 0000000000..30a413faf7 --- /dev/null +++ b/graphify/extractors/cpp_constructor_binding.py @@ -0,0 +1,118 @@ +"""Exact parent and signature proof for constructor declaration/body joins.""" +from __future__ import annotations + +import hashlib + +from graphify.extractors.base import _make_id +from graphify.extractors.cpp_identity import qualified_class_id +from graphify.extractors.cpp_constructor_signature import constructor_signature + + +def within_class(node, fact, owner): + """An extracted parent edge cannot authorize a prototype relocated outside its source class.""" + span, parent_span = fact["span"], owner["metadata"]["cpp_class"]["span"] + return (owner.get("source_file") == node.get("source_file") + and parent_span["start_byte"] < span["start_byte"] < span["end_byte"] < parent_span["end_byte"] + and (parent_span["start_row"], parent_span["start_column"]) < (span["start_row"], span["start_column"]) + and (span["end_row"], span["end_column"]) < (parent_span["end_row"], parent_span["end_column"])) + + +def inline_owner(node, fact, owners, edges): + """An inline body requires one complete source-owned containing class.""" + if fact.get("ambiguous") or fact.get("owner_bound") is not True or len(owners) != 1: + return False + owner = owners[0] + return (within_class(node, fact, owner) + and any(edge.get("source") == owner["id"] and edge.get("target") == node["id"] + and edge.get("relation") == "method" and edge.get("confidence") == "EXTRACTED" + and edge.get("source_file") == node.get("source_file") + and edge.get("source_location") == node.get("source_location") for edge in edges)) + + +def id_authorized(node, owner, signature): + """Signature transport cannot redirect an occurrence into another class.""" + expected = _make_id(qualified_class_id("", owner), owner.rsplit("::", 1)[-1], "cppctor", signature) + # Only the file prefix changes during root remapping. Owner and signature + # must remain the exact producer suffix before and after that operation. + return node["id"] == expected or str(node["id"]).endswith("_" + expected) + + +def syntax_matches(node, declaration, source, owner, *, definition_site=False): + """A Qt source occurrence selects its actual signature and original span.""" + from graphify.extractors.cpp_constructors import _fact, _span + fact = _fact(node, "cpp_constructor") + if not fact: + return False + payload = constructor_signature(declaration, source, owner) + if not payload or fact.get("ambiguous") or hashlib.sha256(payload).hexdigest() != fact["signature"]: + return False + span = fact.get("definition_span") if definition_site else fact["span"] + return span == _span(declaration) + + +def bind_constructors(nodes, edges): + """Join unique complete classes and exact matching overload declarations.""" + from graphify.extractors.cpp_constructors import _fact, _decoded + from graphify.extractors.cpp_constructor_type_authority import ConstructorTypeAuthority + classes = {} + declarations = {} + type_authority = ConstructorTypeAuthority(nodes) + class_ids = {node["id"] for node in nodes if node.get("_callable_class") is True} + for node in nodes: + fact = _fact(node, "cpp_class") + name = _decoded(fact.get("qualified_name_b64")) + if (name and fact.get("is_definition") is True and not fact.get("ambiguous") + and node.get("_callable_class") is True and type_authority.source_span_authorized(node, fact)): + classes.setdefault(name, []).append(node) + fact = _fact(node, "cpp_constructor") + if fact.get("role") == "declaration": + fact["type_authorized"] = type_authority.authorized(node, fact) + declarations.setdefault(_decoded(fact.get("owner_b64")), []).append(node) + for node in nodes: + fact = _fact(node, "cpp_constructor") + if fact.get("role") != "definition": + continue + if not type_authority.authorized(node, fact): + fact.update(owner_bound=False, type_authorized=False) + continue + fact["type_authorized"] = True + owners = classes.get(_decoded(fact.get("owner_b64")), []) + if inline_owner(node, fact, owners, edges): + continue # Accepted inline body already has its exact class parent. + fact["owner_bound"] = False + prototypes = [item for item in declarations.get(_decoded(fact.get("owner_b64")), []) + if _fact(item, "cpp_constructor").get("signature") == fact.get("signature")] + if len(owners) != 1 or len(prototypes) != 1 or fact.get("ambiguous"): + continue + prototype = prototypes[0] + declaration = _fact(prototype, "cpp_constructor") + owner = owners[0] + if (declaration.get("ambiguous") or declaration.get("signature") != fact.get("signature") + or not type_authority.authorized(prototype, declaration) + or not within_class(prototype, declaration, owner) + or not any(edge.get("source") == owner["id"] and edge.get("target") == prototype["id"] + and edge.get("relation") == "method" and edge.get("confidence") == "EXTRACTED" + and edge.get("source_file") == owner.get("source_file") + and edge.get("source_location") == prototype.get("source_location") + for edge in edges)): + continue + # Definition provenance and any pre-existing real parent remain authoritative. + if (not any(edge.get("target") == node["id"] and edge.get("relation") == "contains" + and edge.get("confidence") == "EXTRACTED" and edge.get("source_file") == node.get("source_file") + for edge in edges) + or any(edge.get("target") == node["id"] and (edge.get("relation") == "method" + or (edge.get("relation") == "contains" and edge.get("source") in class_ids)) + and edge.get("confidence") == "EXTRACTED" and edge.get("source") != owner["id"] + for edge in edges) + or any(edge.get("target") == prototype["id"] and (edge.get("relation") == "method" + or (edge.get("relation") == "contains" and edge.get("source") in class_ids)) + and edge.get("confidence") == "EXTRACTED" and edge.get("source") != owner["id"] + for edge in edges)): + continue + fact["owner_bound"] = True + declaration["definition_span"] = dict(fact["span"]) + if not any(edge.get("source") == owner["id"] and edge.get("target") == node["id"] + and edge.get("relation") == "method" for edge in edges): + edges.append({"source": owner["id"], "target": node["id"], "relation": "method", + "confidence": "EXTRACTED", "context": "cpp_constructor_owner", + "source_file": owner["source_file"], "source_location": prototype["source_location"], "weight": 1.0}) diff --git a/graphify/extractors/cpp_constructor_signature.py b/graphify/extractors/cpp_constructor_signature.py new file mode 100644 index 0000000000..07fe39a093 --- /dev/null +++ b/graphify/extractors/cpp_constructor_signature.py @@ -0,0 +1,138 @@ +"""Bounded constructor signature identity before generic node deduplication. + +The accepted profile covers builtin, self-class and QObject parameter types with +literal pointer/reference qualifiers. Unsupported types retain occurrence IDs; +they never authorize a declaration/definition join. No compiler or corpus runs. +""" +from __future__ import annotations + +import base64 +import binascii +import hashlib +import re + +from graphify.extractors.base import _make_id, _read_text + + +def _builtin(value): + words = value.split() + single = {"bool", "char", "wchar_t", "char8_t", "char16_t", "char32_t", "float", "double", "void"} + if len(words) == 1 and words[0] in single: + return words[0] + if words and all(word in {"signed", "unsigned", "short", "long", "int", "char", "double"} for word in words): + if (words.count("long") > 2 or any(words.count(word) > 1 for word in set(words) - {"long"}) + or {"signed", "unsigned"} <= set(words) or {"short", "long"} <= set(words)): + return "" + if "double" in words: + return "long double" if sorted(words) == ["double", "long"] else "" + if "char" in words: + return " ".join(sorted(words)) if len(words) == 2 and set(words) <= {"signed", "unsigned", "char"} else "" + sign = "unsigned " if "unsigned" in words else "" + size = "short" if "short" in words else "long long" if words.count("long") == 2 else "long" if "long" in words else "int" + return sign + size + return "" + + +def _parameter(parameter, source, owner): + """Remove value-only CV and names without erasing pointee/reference types.""" + if parameter.type not in {"parameter_declaration", "optional_parameter_declaration"}: + return "" + type_node = parameter.child_by_field_name("type") + if type_node is None or parameter.end_byte - parameter.start_byte > 1024: + return "" + raw_type = re.sub(r"\s+", " ", _read_text(type_node, source)).strip() + base = _builtin(raw_type) + if not base: + # Aliases and unrelated named types need semantic proof beyond spelling. + if raw_type in {owner, owner.rsplit("::", 1)[-1]}: + base = "self:" + owner + elif raw_type == "QObject": + base = "QObject" + else: + return "" + qualifier = sorted(_read_text(child, source) for child in parameter.children if child.type == "type_qualifier") + if any(value not in {"const", "volatile"} for value in qualifier): + return "" + declarator = parameter.child_by_field_name("declarator") + layers = [] + while declarator and declarator.type not in {"identifier", "field_identifier"}: + if declarator.type not in {"pointer_declarator", "reference_declarator", "abstract_pointer_declarator", "abstract_reference_declarator"}: + return "" + cv = sorted(_read_text(child, source) for child in declarator.children if child.type == "type_qualifier") + if any(value not in {"const", "volatile"} for value in cv): + return "" + symbol = "*" if "pointer" in declarator.type else "&&" if any(child.type == "&&" for child in declarator.children) else "&" + layers.append([symbol, cv]) + declarator = declarator.child_by_field_name("declarator") or next( + (child for child in declarator.named_children if child.type != "type_qualifier"), None) + if layers and layers[-1][0] == "*": + layers[-1][1] = [] # Last lexical layer is the value-level pointer. + # C++ permits a sole unnamed unqualified void as an empty parameter list; + # value-CV normalization must not turn an invalid qualified void into it. + if base == "void" and not layers and qualifier: + return "" + if not layers: + qualifier = [] + if base == "void" and not layers and parameter.child_by_field_name("declarator") is not None: + return "" + if base == "void" and (layers or qualifier): + return "" if not layers else " ".join(qualifier + [base]) + "".join(symbol + " ".join(cv) for symbol, cv in layers) + return " ".join(qualifier + [base]) + "".join(symbol + " ".join(cv) for symbol, cv in layers) + + +def constructor_signature(node, source, owner): + """Return accepted signature bytes or no authority for unsupported syntax.""" + declarator = node.child_by_field_name("declarator") + if declarator is None or declarator.type != "function_declarator" or node.has_error: + return b"" + parent = node.parent + while parent: + if parent.type == "template_declaration" or parent.type.startswith("preproc_"): + return b"" + parent = parent.parent + if any(child.type == "requires_clause" for child in declarator.named_children): + return b"" + params = declarator.child_by_field_name("parameters") + if params is None or len(params.named_children) > 64 or any(child.type == "..." for child in params.children): + return b"" + values = [_parameter(item, source, owner) for item in params.named_children] + if any(not value for value in values) or "void" in values and values != ["void"]: + return b"" + # An explicit version marker distinguishes accepted zero arguments from an + # unsupported signature, and bounds transport below metadata string limits. + payload = ("ctor2\0" + "\0".join([] if values == ["void"] else values)).encode() + return payload if len(payload) <= 360 else b"" + + +def constructor_id(class_id, name, node, source, owner): + """IDs never depend on the number or ordering of neighboring overloads.""" + payload = constructor_signature(node, source, owner) + if not payload: + # A source-owned unsupported occurrence must not collapse onto another + # callable or become a plausible exact signature through normalization. + payload = b"unsupported\0" + str(node.start_byte).encode() + b"\0" + source[node.start_byte:node.end_byte] + digest = hashlib.sha256(payload).hexdigest() + return _make_id(class_id, name, "cppctor", digest) + + +def signature_fact(node, source, owner): + payload = constructor_signature(node, source, owner) + return {"signature": hashlib.sha256(payload).hexdigest() if payload else hashlib.sha256( + b"unsupported\0" + str(node.start_byte).encode() + b"\0" + source[node.start_byte:node.end_byte]).hexdigest(), + "signature_b64": base64.b64encode(payload).decode(), "ambiguous": not bool(payload)} + + +def signature_authorized(node, fact): + """Reject corrupted transport and copying another overload's signature.""" + value = fact.get("signature_b64") + if not isinstance(value, str) or not value or len(value) > 480: + return False + try: + payload = base64.b64decode(value, validate=True) + decoded = payload.decode("utf-8") + except (ValueError, UnicodeError, binascii.Error): + return False + if (not decoded.startswith("ctor2\0") or len(payload) > 360 + or hashlib.sha256(payload).hexdigest() != fact.get("signature")): + return False + return str(node.get("id", "")).endswith("_cppctor_" + fact["signature"]) diff --git a/graphify/extractors/cpp_constructor_type_authority.py b/graphify/extractors/cpp_constructor_type_authority.py new file mode 100644 index 0000000000..d722e4c7eb --- /dev/null +++ b/graphify/extractors/cpp_constructor_type_authority.py @@ -0,0 +1,236 @@ +"""Accepted AST type shadows for the bounded constructor QObject signature. + +File facts retain literal includes and namespace/class shadows before canonical +joins. The corpus lookup consumes only admitted nodes; it discovers no files, +opens no headers and does not emulate preprocessing or resolve alias targets. +""" +from __future__ import annotations + +import base64 +import binascii +import posixpath +import re + +from graphify.extractors.base import _read_text +from graphify.extractors.cpp_identity import lexical_scope + + +def _walk(root): + pending = [root] + while pending: + node = pending.pop() + yield node + pending.extend(reversed(node.named_children)) + + +def _encode(value): + return base64.b64encode(value.encode()).decode() + + +def _decode(value): + if not isinstance(value, str) or len(value) > 480: + return None + try: + decoded = base64.b64decode(value, validate=True).decode("utf-8") + except (ValueError, UnicodeError, binascii.Error): + return None + return decoded if len(decoded.encode()) <= 360 else None + + +def source_facts(root, source, nodes, path): + """The direct generic producer owns the transport, not the Qt facade.""" + from graphify.extractors.cpp_constructors import _span + files = [node for node in nodes if node.get("source_file") == str(path) + and node.get("source_location") == "L1" and node.get("label") == path.name + and not node.get("_callable") and not node.get("_callable_class")] + if len(files) != 1: + return + shadows, includes, complete = [], [], not root.has_error + for syntax in _walk(root): + if syntax.type == "preproc_include": + literal = _read_text(syntax.child_by_field_name("path"), source) + kind = "quoted" if literal.startswith('"') and literal.endswith('"') else "angle" if literal.startswith("<") and literal.endswith(">") else "" + if kind: + value = literal[1:-1] + if (not value or len(value.encode()) > 360 or "\\" in value or ":" in value + or value.startswith("/") or "\0" in value or kind == "angle" and ".." in value.split("/")): + complete = False + else: + includes.append({"literal_b64": _encode(value), "kind": kind, + "end_byte": syntax.end_byte, "span": _span(syntax)}) + else: + complete = False + continue + if syntax.type not in {"alias_declaration", "type_definition", "using_declaration", "class_specifier", "struct_specifier"}: + continue + names = syntax.children_by_field_name("declarator") if syntax.type == "type_definition" else [ + syntax.child_by_field_name("name")] + if syntax.type == "using_declaration": + literal = _read_text(syntax, source).removeprefix("using").strip().removesuffix(";").strip() + names = [] + if literal.startswith("namespace "): + complete = False # A namespace import may supply an unseen alias. + elif literal.rsplit("::", 1)[-1] == "QObject": + names = [syntax] + for name in names: + leaf = name + while leaf and leaf.type not in {"identifier", "type_identifier", "using_declaration"}: + leaf = leaf.child_by_field_name("declarator") or next(iter(leaf.named_children), None) + spelling = _read_text(leaf, source) if leaf else "" + if spelling != "QObject" and not (leaf and leaf.type == "using_declaration"): + continue + scope = lexical_scope(syntax, source) + ancestor = syntax.parent + while ancestor and ancestor.type not in {"function_definition", "lambda_expression", "translation_unit"}: + ancestor = ancestor.parent + if ancestor and ancestor.type in {"function_definition", "lambda_expression"}: + continue # A body-local type cannot shadow its constructor parameter clause. + if scope is None or len(scope.encode()) > 360: + complete = False + else: + parent = syntax.parent + while parent and parent.type not in {"class_specifier", "struct_specifier", "namespace_definition", "translation_unit"}: + parent = parent.parent + shadows.append({"scope_b64": _encode(scope), "start_byte": syntax.start_byte, + "class_scope": bool(parent and parent.type in {"class_specifier", "struct_specifier"}), + "span": _span(syntax)}) + if len(includes) > 50 or len(shadows) > 50: + complete = False + metadata = dict(files[0].get("metadata") or {}) + metadata["cpp_constructor_types"] = {"contract_version": 1, "complete": complete, "source_size": len(source), + "includes": includes[:50], "shadows": shadows[:50]} + files[0]["metadata"] = metadata + + +def valid_source_transport(value): + """Validate file transport without granting its incomplete record type authority. + + Both constructor joins and Qt file-role consumers use the same immutable, + bounded include/shadow validation. This predicate reads only accepted facts. + """ + if (not isinstance(value, dict) or type(value.get("contract_version")) is not int + or value["contract_version"] != 1 or type(value.get("complete")) is not bool + or type(value.get("source_size")) is not int or value["source_size"] < 0): + return False + for key in ("includes", "shadows"): + values = value.get(key) + if not isinstance(values, list) or len(values) > 50: + return False + for item in values: + text_key, byte_key = ("literal_b64", "end_byte") if key == "includes" else ("scope_b64", "start_byte") + if (not isinstance(item, dict) or _decode(item.get(text_key)) is None + or type(item.get(byte_key)) is not int or item[byte_key] < 0): + return False + span = item.get("span") + if (not isinstance(span, dict) or any(type(span.get(field)) is not int or span[field] < 0 + for field in ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column")) + or not span["start_byte"] < span["end_byte"] <= value["source_size"] + or span["start_row"] > span["end_row"] or item[byte_key] != span[byte_key] + or (span["start_row"] == span["end_row"] + and span["end_column"] - span["start_column"] != span["end_byte"] - span["start_byte"])): + return False + text = _decode(item[text_key]) + if key == "shadows" and text and not all(part.isidentifier() for part in text.split("::")): + return False + if key == "shadows" and type(item.get("class_scope")) is not bool: + return False + if key == "includes" and (not text or "\\" in text or ":" in text or text.startswith("/") or "\0" in text + or item.get("kind") not in {"quoted", "angle"} + or item["kind"] == "angle" and ".." in text.split("/")): + return False + return True + + +class ConstructorTypeAuthority: + """Immutable admitted-source inventory; bounded conservative include walk.""" + + def __init__(self, nodes): + self.files = {} + for node in nodes: + if node.get("source_file") and not node.get("_callable") and not node.get("_callable_class"): + file = self._file(node["source_file"]) + metadata = node.get("metadata") + if (isinstance(metadata, dict) and "cpp_constructor_types" in metadata + or node.get("source_location") == "L1" and node.get("label") == posixpath.basename(file)): + self.files.setdefault(self._file(node["source_file"]), []).append(node) + + @staticmethod + def _file(value): + return posixpath.normpath(str(value).replace("\\", "/")) + + def _source_record(self, file): + """File bounds remain usable when include/type proof is conservatively incomplete.""" + found = self.files.get(file, ()) + if len(found) != 1: + return None + node = found[0] + metadata = node.get("metadata") + value = metadata.get("cpp_constructor_types") if isinstance(metadata, dict) else None + if (not isinstance(value, dict) or type(value.get("contract_version")) is not int + or value["contract_version"] != 1 or type(value.get("complete")) is not bool + or type(value.get("source_size")) is not int or value["source_size"] < 0 + or node.get("source_location") != "L1" or node.get("label") != posixpath.basename(file) + or node.get("file_type") != "code" or node.get("_origin") not in {None, "ast"}): + return None + return value + + def source_span_authorized(self, node, fact): + """Transported class/constructor spans must stay inside one admitted original file.""" + record = self._source_record(self._file(node.get("source_file", ""))) + span = fact.get("span") + fields = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column") + return bool(record is not None and isinstance(span, dict) + and all(type(span.get(field)) is int and 0 <= span[field] <= record["source_size"] + for field in fields) + and span["start_byte"] < span["end_byte"] + and (span["start_row"], span["start_column"]) <= (span["end_row"], span["end_column"])) + + def _fact(self, file): + value = self._source_record(file) + if value is None or value["complete"] is not True: + return None + return value if valid_source_transport(value) else None + + def authorized(self, node, fact): + if not self.source_span_authorized(node, fact): + return False + encoded = fact.get("signature_b64", "") + payload = _decode(encoded) + if payload is None or not re.search(r"(? 128 or (record := self._fact(file)) is None: + return False + if any(_decode(item["scope_b64"]) in scopes and (item["class_scope"] or item["start_byte"] <= position) + for item in record["shadows"]): + return False + for include in record["includes"]: + if include["end_byte"] > position: + continue + literal = _decode(include["literal_b64"]) + if not literal: + return False + if include["kind"] == "angle": + # Configured compiler include search is unavailable here. + # Any admitted suffix candidate could supply a source alias, + # even when its transport is missing, corrupt or duplicated. + suffix = posixpath.normpath(literal).casefold() + if any(candidate.casefold() == suffix or candidate.casefold().endswith("/" + suffix) + for candidate in self.files): + return False + continue # An unavailable external SDK header grants no new source shadow. + target = posixpath.normpath(posixpath.join(posixpath.dirname(file), literal)) + if target not in self.files: + return False # Missing quoted source cannot establish type identity. + pending.append((target, 2**63 - 1)) + return True diff --git a/graphify/extractors/cpp_constructors.py b/graphify/extractors/cpp_constructors.py new file mode 100644 index 0000000000..955daeed2a --- /dev/null +++ b/graphify/extractors/cpp_constructors.py @@ -0,0 +1,255 @@ +"""Bounded AST constructor declarations and exact accepted class ownership. + +Constructor IDs retain exact bounded signatures before generic deduplication. +This seam supplies prototypes and source proof; unsupported signatures stay unknown. +""" +from __future__ import annotations + +import base64 +import binascii +import hashlib +import re + +from graphify.extractors.base import _file_stem, _read_text +from graphify.extractors.cpp_identity import CppIdentity, qualified_function_id +from graphify.extractors.cpp_constructor_signature import constructor_id, signature_authorized, signature_fact +from graphify.extractors.cpp_constructor_binding import id_authorized +from graphify.extractors.cpp_constructor_type_authority import ConstructorTypeAuthority, source_facts + + +def _walk(root): + pending = [root] + while pending: + node = pending.pop() + yield node + pending.extend(reversed(node.named_children)) + + +def _span(node): + return {"start_byte": node.start_byte, "end_byte": node.end_byte, + "start_row": node.start_point.row, "end_row": node.end_point.row, + "start_column": node.start_point.column, "end_column": node.end_point.column} + + +def _identifier(value): + return bool(value and len(value.encode("utf-8")) <= 384 + and all(part.isidentifier() for part in value.split("::"))) + + +def _encoded(value): + return base64.b64encode(value.encode("utf-8")).decode("ascii") + + +def _decoded(value): + if not isinstance(value, str) or len(value) > 512: + return "" + try: + result = base64.b64decode(value, validate=True).decode("utf-8") + except (ValueError, UnicodeError, binascii.Error): + return "" + return result if _identifier(result) else "" + + +def _scope(node, source): + """Only lexical named namespace/class scopes authorize qualified ownership.""" + parts = [] + parent = node.parent + while parent: + if parent.type in {"function_definition", "lambda_expression"}: + return None + if parent.type in {"namespace_definition", "class_specifier", "struct_specifier"}: + name_node = parent.child_by_field_name("name") + name = _read_text(name_node, source) if name_node else "" + if not _identifier(name): + return None + parts.append(name) + parent = parent.parent + return "::".join(reversed(parts)) + + +def _signature(declarator, source): + params = declarator.child_by_field_name("parameters") + if params is None or len(params.named_children) > 64 or any(child.type == "..." for child in params.children): + return "" + values = [] + for parameter in params.named_children: + if parameter.type not in {"parameter_declaration", "optional_parameter_declaration", "variadic_parameter_declaration"}: + return "" + end = parameter.end_byte + default = parameter.child_by_field_name("default_value") + if default: + end = source.rfind(b"=", parameter.start_byte, default.start_byte) + if end < parameter.start_byte or end - parameter.start_byte > 512: + return "" + name = parameter.child_by_field_name("declarator") + while name and name.type not in {"identifier", "field_identifier"}: + name = name.child_by_field_name("declarator") or next( + (child for child in name.named_children if child.type in {"identifier", "field_identifier", "reference_declarator", "pointer_declarator", "parenthesized_declarator", "function_declarator"}), None) + text = source[parameter.start_byte:end] + if name: + offset = name.start_byte - parameter.start_byte + text = text[:offset] + text[offset + name.end_byte - name.start_byte:] + # Preserve word boundaries: `unsigned int` is not the alias `unsignedint`. + text = re.sub(rb"\s+", b" ", text).strip() + values.append(re.sub(rb"\s*([*&<>,:\[\]()])\s*", rb"\1", text)) + if values == [b"void"]: + values = [] + return hashlib.sha256(b"\0".join(values)).hexdigest() + + +def _fact(node, key): + metadata = node.get("metadata") + value = metadata.get(key) if isinstance(metadata, dict) else None + if (not isinstance(value, dict) or type(value.get("contract_version")) is not int + or value["contract_version"] != 1 or type(value.get("ambiguous")) is not bool): + return {} + span = value.get("span") + fields = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column") + if (not isinstance(span, dict) or any(type(span.get(field)) is not int or span[field] < 0 for field in fields) + or span["end_byte"] <= span["start_byte"] or span["end_row"] < span["start_row"] + or (span["end_row"] == span["start_row"] and span["end_column"] - span["start_column"] != span["end_byte"] - span["start_byte"])): + return {} + if not node.get("source_file") or node.get("source_location") != f"L{span['start_row'] + 1}": + return {} + scope_value = value.get("scope_b64") + scope = "" if scope_value == "" else _decoded(scope_value) + if not isinstance(scope_value, str) or (scope_value and not scope): + return {} + if key == "cpp_class": + valid = type(value.get("is_definition")) is bool and _decoded(value.get("qualified_name_b64")) + expected = f"{scope}::{node.get('label')}" if scope else node.get("label") + valid = valid and _decoded(value["qualified_name_b64"]) == expected + else: + valid = (isinstance(value.get("role"), str) and value["role"] in {"declaration", "definition"} and _decoded(value.get("owner_b64")) + and node.get("_callable") is True and node.get("_callable_class") is not True + and isinstance(value.get("signature"), str) and re.fullmatch(r"[0-9a-f]{64}", value["signature"])) + name = _decoded(value.get("name_b64")) + label = str(node.get("label", "")).removesuffix("()").removeprefix(".") + owner = _decoded(value.get("owner_b64")) + original_owner, _, short = name.rpartition("::") + expected = f"{scope}::{original_owner}" if scope and original_owner else original_owner or scope + valid = (valid and name == label and owner.split("::")[-1] == (short if original_owner else name) + and owner == expected) + if key == "cpp_constructor": + valid = valid and (value["ambiguous"] or signature_authorized(node, value)) and id_authorized(node, owner, value["signature"]) + return value if valid else {} + + +def _store(node, key, fact): + metadata = dict(node.get("metadata") or {}) + previous = metadata.get(key) + if previous: + fact["ambiguous"] = True + metadata[key] = fact + node["metadata"] = metadata + + +def augment_cpp_constructors(root, source, nodes, edges, path): + """Produce bounded exact source facts; never execute or reparse the corpus.""" + if root.has_error: + return + source_facts(root, source, nodes, path) + by_id = {node["id"]: node for node in nodes} + identities = CppIdentity(root, source, _file_stem(path)) + classes = {} + syntax = list(_walk(root)) + for item in syntax: + if item.type not in {"class_specifier", "struct_specifier"}: + continue + name_node = item.child_by_field_name("name") + name = _read_text(name_node, source) if name_node else "" + scope = _scope(item, source) + if scope is None or not _identifier(name): + continue + record = identities.record(item) + owner = by_id.get(record["id"]) if record else None + if owner is None or owner.get("_callable_class") is not True: + continue + qualified = f"{scope}::{name}" if scope else name + if not _identifier(qualified): + continue + if identities.preferred(record) is record: + fact = {"contract_version": 1, "qualified_name_b64": _encoded(qualified), + "scope_b64": _encoded(scope), + "is_definition": item.child_by_field_name("body") is not None, + "span": _span(item), "ambiguous": identities.ambiguous(record)} + _store(owner, "cpp_class", fact) + classes[item.id] = (owner, qualified) + for item in syntax: + if item.type not in {"declaration", "function_definition"} or item.child_by_field_name("type") is not None: + continue + declarator = item.child_by_field_name("declarator") + if declarator is None or declarator.type != "function_declarator": + continue + name_node = declarator.child_by_field_name("declarator") + name = _read_text(name_node, source) if name_node else "" + if not _identifier(name): + continue + scope = _scope(item, source) + if scope is None: + continue + parent = item.parent + while parent and parent.type not in {"class_specifier", "struct_specifier", "translation_unit", "namespace_definition"}: + parent = parent.parent + owner_info = classes.get(parent.id) if parent else None + if owner_info and name == owner_info[1].split("::")[-1]: + owner, qualified = owner_info + nid = constructor_id(owner["id"], name, item, source, qualified) + if nid not in by_id: + node = {"id": nid, "label": f".{name}()", "file_type": "code", + "source_file": str(path), "source_location": f"L{item.start_point.row + 1}", "_callable": True} + nodes.append(node) + by_id[nid] = node + edges.append({"source": owner["id"], "target": nid, "relation": "method", + "confidence": "EXTRACTED", "source_file": str(path), + "source_location": node["source_location"], "weight": 1.0}) + role = "definition" if item.type == "function_definition" else "declaration" + elif not owner_info and item.type == "function_definition" and "::" in name: + qualified, short = name.rsplit("::", 1) + if qualified.split("::")[-1] != short or item.child_by_field_name("body") is None: + continue + qualified = f"{scope}::{qualified}" if scope else qualified + nid = qualified_function_id(_file_stem(path), item, name, source) + if nid not in by_id or not _identifier(qualified): + continue + role = "definition" + else: + continue + _store(by_id[nid], "cpp_constructor", {"contract_version": 1, "owner_b64": _encoded(qualified), + "scope_b64": _encoded(scope), + "name_b64": _encoded(name), "role": role, "span": _span(item), + "owner_bound": bool(owner_info and role == "definition"), **signature_fact(item, source, qualified)}) + type_authority = ConstructorTypeAuthority(nodes) + for node in nodes: + fact = _fact(node, "cpp_constructor") + if fact: + fact["type_authorized"] = type_authority.authorized(node, fact) + if not fact["type_authorized"]: + fact["owner_bound"] = False + + +def bind_cpp_constructors(nodes, edges): + """Public constructor seam delegates to the single corpus binding owner.""" + from graphify.extractors.cpp_constructor_binding import bind_constructors + bind_constructors(nodes, edges) + + +def constructor_merge_allowed(group): + """A header prototype cannot hide an unmatched or ambiguous implementation.""" + facts = [_fact(node, "cpp_constructor") for node in group] + if any(isinstance(node.get("metadata"), dict) and "cpp_constructor" in node["metadata"] and not fact + for node, fact in zip(group, facts)): + return False + return not (any(fact.get("ambiguous") for fact in facts) + or sum(fact.get("role") == "definition" for fact in facts) > 1 + or any(fact.get("role") == "definition" and fact.get("owner_bound") is not True for fact in facts)) + + +def constructor_class_authorized(node): + """A Qt consumer may retain the callable while rejecting its class authority.""" + metadata = node.get("metadata") + if not isinstance(metadata, dict) or "cpp_constructor" not in metadata: + return True + fact = _fact(node, "cpp_constructor") + return bool(fact and not fact["ambiguous"] and fact.get("type_authorized") is True + and (fact["role"] == "declaration" or fact.get("owner_bound") is True)) diff --git a/graphify/extractors/cpp_identity.py b/graphify/extractors/cpp_identity.py new file mode 100644 index 0000000000..316b170e40 --- /dev/null +++ b/graphify/extractors/cpp_identity.py @@ -0,0 +1,125 @@ +"""Exact named C++ class scopes own qualified IDs before generic deduplication. + +Global IDs retain the existing contract. A qualified owner adds a digest of its +case-sensitive source spelling: ordinary ID normalization must not conflate +namespace separators, underscores or case. No runtime or compiler is consulted. +""" +from __future__ import annotations + +import hashlib + +from graphify.extractors.base import _make_id, _read_text + + +def _walk(root): + pending = [root] + while pending: + node = pending.pop() + yield node + pending.extend(reversed(node.named_children)) + + +def _name(value): + return bool(value and len(value.encode()) <= 384 + and all(part.isidentifier() for part in value.split("::"))) + + +def lexical_scope(node, source): + """Anonymous/function-local scopes cannot establish a named native owner.""" + parts, parent = [], node.parent + while parent: + if parent.type in {"function_definition", "lambda_expression"}: + return None + if parent.type in {"namespace_definition", "class_specifier", "struct_specifier"}: + name = _read_text(parent.child_by_field_name("name"), source) if parent.child_by_field_name("name") else "" + if not _name(name): + return None + parts.append(name) + parent = parent.parent + return "::".join(reversed(parts)) + + +def qualified_class_id(stem, qualified): + """Keep plain global IDs; salt exact qualified names before case folding.""" + if "::" not in qualified or not _name(qualified): + return _make_id(stem, qualified) + digest = hashlib.sha256(qualified.encode()).hexdigest() + return _make_id(stem, "cppq", digest, qualified.rsplit("::", 1)[-1][:32]) + + +def qualified_function_id(stem, node, name, source): + """Qualified out-of-line methods use the same actual owner ID as the header.""" + scope = lexical_scope(node, source) + if scope is None or "::" not in name or not _name(name): + return _make_id(stem, name) + owner, member = name.rsplit("::", 1) + owner = "::".join(filter(None, (scope, owner))) + class_id = qualified_class_id(stem, owner) + if (member == owner.rsplit("::", 1)[-1] + and node.child_by_field_name("type") is None): + from graphify.extractors.cpp_constructor_signature import constructor_id + return constructor_id(class_id, member, node, source, owner) + return _make_id(class_id, member) + + +class CppIdentity: + """One immutable AST index; forward facts cannot hide a complete body.""" + + def __init__(self, root, source, stem): + self.source, self.stem = source, stem + self.records, self.groups = {}, {} + for node in _walk(root): + if node.type not in {"class_specifier", "struct_specifier"}: + continue + name_node = node.child_by_field_name("name") + name = _read_text(name_node, source) if name_node else "" + scope = lexical_scope(node, source) + qualified = "::".join(filter(None, (scope, name))) if scope is not None else "" + if scope is None or not _name(qualified): + continue + record = {"syntax": node, "name": name, "scope": scope, + "qualified": qualified, "id": qualified_class_id(stem, qualified), + "is_definition": node.child_by_field_name("body") is not None} + self.records[node.id] = record + self.groups.setdefault(qualified, []).append(record) + + def record(self, node): + return self.records.get(node.id) + + def constructor(self, node, name, parent_id): + """Inline constructors use the same exact owner/signature as prototypes.""" + parent = node.parent + while parent and parent.type not in {"class_specifier", "struct_specifier", "translation_unit"}: + parent = parent.parent + record = self.record(parent) if parent else None + if (not record or record["id"] != parent_id or name != record["name"] + or node.child_by_field_name("type") is not None): + return _make_id(parent_id, name) + from graphify.extractors.cpp_constructor_signature import constructor_id + return constructor_id(parent_id, name, node, self.source, record["qualified"]) + + def preferred(self, record): + group = self.groups[record["qualified"]] + complete = [item for item in group if item["is_definition"]] + return (complete or group)[0] + + def ambiguous(self, record): + return sum(item["is_definition"] for item in self.groups[record["qualified"]]) > 1 + + def reference(self, name, node): + """Resolve exact in-file class scopes without borrowing a basename.""" + if not isinstance(name, str) or not _name(name.removeprefix("::")): + return "" + scope = lexical_scope(node, self.source) + if scope is None: + return "" + choices = [""] if name.startswith("::") else [ + "::".join(scope.split("::")[:end]) for end in range(len(scope.split("::")), -1, -1)] + raw = name.removeprefix("::") + for prefix in choices: + qualified = "::".join(filter(None, (prefix, raw))) + visible = [record for record in self.groups.get(qualified, ()) + if record["syntax"].start_byte <= node.start_byte] + if visible: + return visible[0]["id"] + return "" diff --git a/graphify/extractors/cpp_member_identity.py b/graphify/extractors/cpp_member_identity.py new file mode 100644 index 0000000000..4f5b3ca862 --- /dev/null +++ b/graphify/extractors/cpp_member_identity.py @@ -0,0 +1,192 @@ +"""Source-proven ordinary C++ member joins, including literal using namespaces. + +Qualified source spelling, singleton header signature and accepted class ownership +must agree before changing an implementation ID. A basename alone never binds it. +""" +from __future__ import annotations + +from pathlib import Path +import hashlib +import re + +from graphify.extractors.base import _file_stem, _make_id, _read_text +from graphify.extractors.cpp_class_proof import class_fact +from graphify.extractors.cpp_constructors import _decoded, _encoded, _identifier, _signature, _span, _store, _walk +from graphify.extractors.cpp_identity import CppIdentity, lexical_scope, qualified_function_id + +_HEADERS = {".h", ".hh", ".hpp", ".hxx"} +_IMPL = {".cpp", ".cc", ".cxx", ".c++"} + + +def _member_signature(declarator, source): + parameter_signature = _signature(declarator, source) + if not parameter_signature: + return "" + qualifiers = [_read_text(child, source) for child in declarator.children + if child.type in {"type_qualifier", "ref_qualifier"}] + return hashlib.sha256("\0".join([parameter_signature, *qualifiers]).encode()).hexdigest() + + +def _visible_usings(root, source, scope, position): + values = [] + for node in _walk(root): + if node.type != "using_declaration" or node.start_byte >= position: + continue + literal = _read_text(node, source).strip() + prefix = "using namespace " + target = literal[len(prefix):].removesuffix(";").strip() if literal.startswith(prefix) else "" + local = lexical_scope(node, source) + parent, conditional = node.parent, False + while parent: + conditional |= parent.type.startswith("preproc_") + parent = parent.parent + if (not conditional and local is not None and _identifier(target) + and (not local or scope == local or scope.startswith(local + "::"))): + values.append(_encoded(target)) + unique = sorted(set(values)) + # Discarding an overflowed alternative could turn real ambiguity into a + # false singleton owner. Keep the member fact, but give no join authority. + return unique if len(unique) <= 50 else None + + +def augment_cpp_members(root, source, nodes, path): + """Stamp bounded original AST facts before corpus canonicalization.""" + if root.has_error: + return + identities = CppIdentity(root, source, _file_stem(path)) + by_id = {node["id"]: node for node in nodes} + for item in _walk(root): + if item.type != "function_declarator": + continue + syntax = item.parent + while syntax and syntax.type not in {"field_declaration", "function_definition"}: + syntax = syntax.parent + if syntax is None or syntax.child_by_field_name("type") is None: + continue + name_node = item.child_by_field_name("declarator") + name = _read_text(name_node, source) if name_node else "" + signature, scope = _member_signature(item, source), lexical_scope(syntax, source) + if not _identifier(name) or not signature or scope is None: + continue + parent = syntax.parent + while parent and parent.type not in {"class_specifier", "struct_specifier", "translation_unit", "namespace_definition"}: + parent = parent.parent + record = identities.record(parent) if parent else None + if record: + owner, member, role = record["qualified"], name, "declaration" + nid = _make_id(record["id"], name) + elif syntax.type == "function_definition" and "::" in name: + raw_owner, member = name.rsplit("::", 1) + owner = "::".join(filter(None, (scope, raw_owner))) + role = "definition" + nid = qualified_function_id(_file_stem(path), syntax, name, source) + else: + continue + if nid not in by_id or not _identifier(owner) or not _identifier(member): + continue + usings = _visible_usings(root, source, scope, syntax.start_byte) if role == "definition" else [] + _store(by_id[nid], "cpp_member", { + "contract_version": 1, "owner_b64": _encoded(owner), "name_b64": _encoded(name), + "scope_b64": _encoded(scope), "member_b64": _encoded(member), "signature": signature, + "role": role, "span": _span(syntax), "ambiguous": usings is None, + "using_b64": usings or [], "using_status": "unsupported" if usings is None else "resolved", + }) + + +def member_fact(node): + value = node.get("metadata", {}).get("cpp_member") + if (not isinstance(value, dict) or type(value.get("contract_version")) is not int + or value["contract_version"] != 1 or type(value.get("ambiguous")) is not bool + or value.get("role") not in {"declaration", "definition"} + or node.get("_callable") is not True or node.get("_callable_class") is True + or not re.fullmatch(r"[0-9a-f]{64}", str(value.get("signature", "")))): + return {} + name, owner, member = (_decoded(value.get(key)) for key in ("name_b64", "owner_b64", "member_b64")) + scope = "" if value.get("scope_b64") == "" else _decoded(value.get("scope_b64")) + span = value.get("span", {}) + if (not name or not owner or not member or not isinstance(value.get("scope_b64"), str) + or (value["scope_b64"] and not scope) + or str(node.get("label", "")).removesuffix("()").removeprefix(".") != name + or name.rsplit("::", 1)[-1] != member or not isinstance(span, dict) + or any(type(span.get(key)) is not int or span[key] < 0 for key in + ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column")) + or span["end_byte"] <= span["start_byte"] or span["end_row"] < span["start_row"] + or (span["end_row"] == span["start_row"] + and span["end_column"] - span["start_column"] != span["end_byte"] - span["start_byte"]) + or node.get("source_location") != f"L{span['start_row'] + 1}" + or not node.get("source_file")): + return {} + expected = scope if value["role"] == "declaration" else "::".join(filter(None, (scope, name.rsplit("::", 1)[0]))) + usings = value.get("using_b64") + if (expected != owner or not isinstance(usings, list) or len(usings) > 50 + or any(not _decoded(item) for item in usings)): + return {} + return value + + +def _siblings(header, implementation): + left, right = Path(str(header)), Path(str(implementation)) + return left.suffix.lower() in _HEADERS and right.suffix.lower() in _IMPL and left.with_suffix("") == right.with_suffix("") + + +def canonicalize_cpp_members(nodes, edges, raw_calls=()): + """Bind only unique complete source owners and exact extracted prototypes.""" + owners, prototypes = {}, {} + for node in nodes: + fact = class_fact(node) + if fact and fact["is_definition"] and not fact["ambiguous"]: + owners.setdefault(_decoded(fact["qualified_name_b64"]), []).append(node) + fact = member_fact(node) + if fact and fact["role"] == "declaration" and not fact["ambiguous"]: + prototypes.setdefault((_decoded(fact["owner_b64"]), _decoded(fact["member_b64"]), fact["signature"]), []).append(node) + remap = {} + for node in nodes: + fact = member_fact(node) + if not fact or fact["role"] != "definition" or fact["ambiguous"]: + continue + # Original file containment proves this is an accepted definition site; + # copying a plausible member fact onto an arbitrary callable is unsafe. + file_id = _make_id(str(node["source_file"])) + file_nodes = [item for item in nodes if item["id"] == file_id + and item.get("source_file") == node["source_file"] + and item.get("source_location") == "L1" + and not item.get("_callable") and not item.get("_callable_class")] + if len(file_nodes) != 1 or not any( + edge.get("source") == file_id and edge.get("target") == node["id"] + and edge.get("relation") == "contains" and edge.get("confidence") == "EXTRACTED" + and edge.get("source_file") == node["source_file"] + and edge.get("source_location") == node["source_location"] for edge in edges): + continue + raw_owner = _decoded(fact["owner_b64"]) + names = {raw_owner} | {_decoded(item) + "::" + raw_owner for item in fact["using_b64"]} + candidates = [(name, owner) for name in names for owner in owners.get(name, ()) + if _siblings(owner.get("source_file"), node.get("source_file"))] + if len(candidates) != 1: + continue + name, owner = candidates[0] + found = prototypes.get((name, _decoded(fact["member_b64"]), fact["signature"]), ()) + found = [target for target in found if target.get("source_file") == owner.get("source_file") + and any(edge.get("source") == owner["id"] and edge.get("target") == target["id"] + and edge.get("relation") == "method" and edge.get("confidence") == "EXTRACTED" + and edge.get("source_file") == owner.get("source_file") + and edge.get("source_location") == target.get("source_location") for edge in edges)] + if len(found) != 1: + continue + fact["bound_owner_b64"] = _encoded(name) + remap[node["id"]] = found[0]["id"] + node["id"] = found[0]["id"] + for edge in edges: + for key in ("source", "target"): + edge[key] = remap.get(edge.get(key), edge.get(key)) + for call in raw_calls: + call["source"] = remap.get(call.get("source"), call.get("source")) + + +def member_merge_allowed(group): + facts = [member_fact(node) for node in group] + if not any("cpp_member" in node.get("metadata", {}) for node in group): + return True + if any(not fact or fact["ambiguous"] for fact in facts): + return False + return (len({fact["signature"] for fact in facts}) == 1 + and len({_decoded(fact.get("bound_owner_b64", fact["owner_b64"])) for fact in facts}) == 1) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 3db8e7b09f..501c0be3f2 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2010,6 +2010,14 @@ def _get_cpp_func_name(node, source: bytes) -> str | None: decl = node.child_by_field_name("declarator") if decl: return _get_cpp_func_name(decl, source) + if node.type == "reference_declarator": + # tree-sitter-cpp places a reference-return function directly beneath + # this wrapper without a declarator field. Only a sole actual function + # child can supply the name; unrelated or competing children stay opaque. + functions = [child for child in node.children if child.type == "function_declarator"] + if len(functions) == 1: + return _get_cpp_func_name(functions[0], source) + return None for child in node.children: if child.type == "identifier": return _read_text(child, source) @@ -4188,6 +4196,10 @@ def _extract_generic( stem = _file_stem(path) str_path = str(path) + cpp_identity = None + if config.ts_module == "tree_sitter_cpp": + from graphify.extractors.cpp_identity import CppIdentity + cpp_identity = CppIdentity(root, source, stem) # Names bound by an import of a module outside the corpus. Module-scoped, so it # is computed once per file and consulted from every scope — see # `_js_external_import_names`. @@ -4338,7 +4350,13 @@ def add_edge(src: str, tgt: str, relation: str, line: int, edge["metadata"] = sanitize_metadata(metadata) edges.append(edge) - def ensure_named_node(name: str, line: int) -> str: + def ensure_named_node(name: str, line: int, syntax=None) -> str: + if cpp_identity is not None and syntax is not None: + accepted_class = cpp_identity.reference(name, syntax) + if accepted_class: + # A known later body will materialize during the AST walk; + # adding a stub now would hide it through seen_ids. + return accepted_class nid = _make_id(stem, ".".join(namespace_stack), name) if nid in seen_ids: return nid @@ -4459,6 +4477,9 @@ def walk(node, parent_class_nid: str | None = None) -> None: ruby_segments = class_name.split("::") class_name = "::".join(ruby_namespace + ruby_segments) class_nid = _make_id(stem, ".".join(namespace_stack), class_name) + cpp_record = cpp_identity.record(node) if cpp_identity is not None else None + if cpp_record: + class_nid = cpp_record["id"] line = node.start_point[0] + 1 metadata = None ruby_reopened = ( @@ -4498,6 +4519,12 @@ def walk(node, parent_class_nid: str | None = None) -> None: ruby_body, source ): metadata["ruby_lookup_unsafe"] = True + if cpp_record and cpp_identity is not None and cpp_identity.preferred(cpp_record) is cpp_record: + existing_class = next((item for item in nodes if item["id"] == class_nid), None) + if existing_class is not None: + # Preserve one declaration identity while making the exact + # complete body authoritative over an earlier forward. + existing_class["source_location"] = f"L{line}" add_node(class_nid, class_name, line, metadata=metadata) if config.ts_module == "tree_sitter_ruby" and metadata: # Reopened declarations collapse onto the same file-local id; @@ -5130,11 +5157,9 @@ def scala_base_name(type_node) -> str | None: if sub.type == "type_identifier": base = _read_text(sub, source) elif sub.type == "qualified_identifier": - # Use the unqualified tail so "std::vector" matches - # a "vector" node id if one exists in the graph; - # fall back to the full qualified text otherwise. - tail = sub.child_by_field_name("name") - base = _read_text(tail, source) if tail else _read_text(sub, source) + # Explicit qualification cannot borrow an unrelated + # local class that merely has the same basename. + base = _read_text(sub, source) elif sub.type == "template_type": tname = sub.child_by_field_name("name") base = _read_text(tname, source) if tname else _read_text(sub, source) @@ -5148,7 +5173,7 @@ def scala_base_name(type_node) -> str | None: continue if not base: continue - base_nid = ensure_named_node(base, line) + base_nid = ensure_named_node(base, line, sub) add_edge(class_nid, base_nid, "inherits", line) # Emit a generic_arg reference for each type argument on the # base (Base -> Car references Dep). _cpp_collect_type_refs @@ -5159,7 +5184,7 @@ def scala_base_name(type_node) -> str | None: if arg.is_named: _cpp_collect_type_refs(arg, source, True, arg_refs) for ref_name, _role in arg_refs: - target_nid = ensure_named_node(ref_name, line) + target_nid = ensure_named_node(ref_name, line, arg) if target_nid != class_nid: add_edge(class_nid, target_nid, "references", line, context="generic_arg") @@ -5640,7 +5665,7 @@ def scala_base_name(type_node) -> str | None: _cpp_collect_type_refs(type_node, source, False, refs) for ref_name, role in refs: ctx = "generic_arg" if role == "generic_arg" else "field" - target_nid = ensure_named_node(ref_name, line) + target_nid = ensure_named_node(ref_name, line, type_node) if target_nid != parent_class_nid: add_edge(parent_class_nid, target_nid, "references", line, context=ctx) @@ -5770,6 +5795,10 @@ def scala_base_name(type_node) -> str | None: ruby_method_kind = "ambiguous" if parent_class_nid: func_nid = _make_id(parent_class_nid, sanitized_name) + # C++ overload identity belongs to the original constructor + # signature before generic deduplication loses occurrence spans. + if cpp_identity is not None: + func_nid = cpp_identity.constructor(node, sanitized_name, parent_class_nid) if config.ts_module == "tree_sitter_python": func_nid = _python_underscore_salted_nid( func_nid, sanitized_name, python_underscore_groups @@ -5803,6 +5832,9 @@ def scala_base_name(type_node) -> str | None: add_edge(parent_class_nid, func_nid, "method", line) else: func_nid = _make_id(stem, sanitized_name) + if cpp_identity is not None: + from graphify.extractors.cpp_identity import qualified_function_id + func_nid = qualified_function_id(stem, node, sanitized_name, source) if config.ts_module == "tree_sitter_python": func_nid = _python_underscore_salted_nid( func_nid, sanitized_name, python_underscore_groups @@ -6087,7 +6119,7 @@ def scala_base_name(type_node) -> str | None: collect(return_node, source, False, refs) for ref_name, role in refs: ctx = "generic_arg" if role == "generic_arg" else "return_type" - target_nid = ensure_named_node(ref_name, line) + target_nid = ensure_named_node(ref_name, line, return_node) if target_nid != func_nid: add_edge(func_nid, target_nid, "references", line, context=ctx) # function_declarator may be wrapped in pointer/reference declarators @@ -7953,6 +7985,13 @@ def _scan_js_module_dispatch(n) -> None: result["ts_type_table"] = {"path": str_path, "table": type_table} elif config.ts_module == "tree_sitter_cpp": result["cpp_type_table"] = {"path": str_path, "table": type_table} + # Constructor prototypes use C++ `declaration`, unlike ordinary member fields. + # Retain their exact source proof before corpus canonicalization owns the join. + if config.ts_module == "tree_sitter_cpp": + from graphify.extractors.cpp_constructors import augment_cpp_constructors + augment_cpp_constructors(root, source, nodes, clean_edges, path) + from graphify.extractors.cpp_member_identity import augment_cpp_members + augment_cpp_members(root, source, nodes, path) return result def _python_decorator_name(deco_node, source: bytes) -> str | None: diff --git a/graphify/extractors/qml.py b/graphify/extractors/qml.py new file mode 100644 index 0000000000..cb83914701 --- /dev/null +++ b/graphify/extractors/qml.py @@ -0,0 +1,34 @@ +"""Dedicated Qt 6 QML extractor with safe failures and explicit scan roots.""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extractors.qml_ast import QmlInputError, failure, parse_source +from graphify.extractors.qml_declarations import Declarations +from graphify.extractors.qml_facts import FactBuilder +from graphify.extractors.qml_expressions import collect_relationships + + +def extract_qml(path: Path, *, root: Path | None = None) -> dict: + try: + if root is not None: + try: + path.resolve().relative_to(root.resolve()) + except ValueError as exc: + raise QmlInputError("QML_ROOT", "QML source is outside the explicit scan root") from exc + source, program, empty = parse_source(path) + facts = FactBuilder(path, root, source) + declarations = Declarations(facts) + declarations.extract(program) + collect_relationships(declarations) + result = {"nodes": facts.nodes, "edges": facts.edges, "diagnostics": []} + if empty: + result["diagnostics"].append({"code": "QML_EMPTY", "severity": "info", + "source_file": facts.relative_file, + "owner": "qml", "message": "Empty editor file has no declarations", + "recovery": "Add a component to enable semantic analysis."}) + return result + except (QmlInputError, ValueError) as exc: + return failure(path, root, exc) + except Exception: + return failure(path, root, QmlInputError("QML_ANALYSIS_FAILED", "QML source analysis failed; correct the analyzer and retry")) diff --git a/graphify/extractors/qml_ast.py b/graphify/extractors/qml_ast.py new file mode 100644 index 0000000000..30f1f985b7 --- /dev/null +++ b/graphify/extractors/qml_ast.py @@ -0,0 +1,73 @@ +"""Lazy offline parser loading and bounded QML input validation.""" +from __future__ import annotations + +from pathlib import Path +from graphify.extractors.qml_source_identity import relative_qml_source + + +class QmlInputError(Exception): + def __init__(self, code: str, message: str): + self.code = code + super().__init__(message) + + +def load_parser(): + try: + from tree_sitter_language_pack import get_parser + except ImportError as exc: + raise QmlInputError("QML_PARSER_MISSING", "QML parser not installed; install graphifyy[qml]") from exc + try: + return get_parser("qmljs") + except Exception as exc: + raise QmlInputError("QML_PARSER_LOAD", "QML parser failed to load; reinstall the pinned qml extra") from exc + + +def parse_source(path: Path): + try: + if path.stat().st_size > 5_000_000: + raise QmlInputError("QML_LIMIT", "QML input exceeds 5 MB") + with path.open("rb") as stream: + source = stream.read(5_000_001) + source.decode("utf-8") + except (OSError, UnicodeError) as exc: + raise QmlInputError("QML_READ", "Cannot read UTF-8 QML source") from exc + if len(source) > 5_000_000: + raise QmlInputError("QML_LIMIT", "QML input exceeds 5 MB") + try: + tree = load_parser().parse(source) + except QmlInputError: + raise + except Exception as exc: + raise QmlInputError("QML_PARSER_LOAD", "QML parser failed to load or parse source; reinstall the pinned extra") from exc + # Empty editor files are explicit facts, not executable QML components. + if not source.strip(): + return source, tree.root_node, True + if tree.root_node.has_error: + raise QmlInputError("QML_SYNTAX", "QML syntax is incomplete or invalid; fix source before publication") + if any(child.type not in {"comment", "ui_pragma", "ui_import", "ui_object_definition"} + for child in tree.root_node.named_children): + raise QmlInputError("QML_UNSUPPORTED", "Unsupported top-level QML syntax in this profile") + pending = [(tree.root_node, 0)] + count = 0 + while pending: + node, depth = pending.pop() + count += 1 + if count > 100_000 or depth > 256: + raise QmlInputError("QML_LIMIT", "QML AST exceeds the supported size/depth bound") + pending.extend((child, depth + 1) for child in node.named_children) + return source, tree.root_node, False + + +def failure(path: Path, root: Path | None, error: Exception) -> dict: + try: + # Diagnostics borrow the admitted lexical label without creating facts + # or target authority. A failed identity lookup must not rethrow here. + name = relative_qml_source(path, root) + except (OSError, RuntimeError, ValueError): + name = "" if getattr(error, "code", "") == "SOURCE_INPUT_IDENTITY_FAILED" else path.name + code = getattr(error, "code", "QML_ROOT" if str(error).startswith("QML_ROOT:") else "QML_LIMIT") + diagnostic = {"code": code, "severity": "error", "source_file": name, + "owner": "qml", "message": str(error), + "recovery": "Correct source or install the qml extra, then retry; prior graph is preserved."} + return {"nodes": [], "edges": [], "error": str(error), + "diagnostics": [diagnostic], "qml_failures": [{"code": code, "source_file": name}]} diff --git a/graphify/extractors/qml_cmake.py b/graphify/extractors/qml_cmake.py new file mode 100644 index 0000000000..556976b54b --- /dev/null +++ b/graphify/extractors/qml_cmake.py @@ -0,0 +1,119 @@ +"""Qt 6 literal qt_add_qml_module records; never execute CMake or expand variables.""" +from __future__ import annotations + +import hashlib +import re +from pathlib import Path + +from graphify.extractors.qml_cmake_syntax import commands +from graphify.extractors.qml_project_read import ProjectFacts, extract_project, failure + +_SINGLE = {"URI", "VERSION", "RESOURCE_PREFIX", "OUTPUT_DIRECTORY", "TYPEINFO", + "PLUGIN_TARGET", "CLASS_NAME", "NAMESPACE"} +_LIST = {"QML_FILES", "SOURCES", "RESOURCES", "IMPORTS", "DEPENDENCIES", "OPTIONAL_IMPORTS", + "DEFAULT_IMPORTS", "IMPORT_PATH", "PAST_MAJOR_VERSIONS"} +_FLAGS = {"STATIC", "SHARED", "NO_PLUGIN", "NO_PLUGIN_OPTIONAL", "NO_CREATE_PLUGIN_TARGET", + "NO_GENERATE_PLUGIN_SOURCE", "NO_GENERATE_QMLTYPES", "NO_GENERATE_QMLDIR", + "NO_GENERATE_EXTRA_QMLDIRS", "NO_LINT", "NO_CACHEGEN", "NO_RESOURCE_TARGET_PATH", + "NO_IMPORT_SCAN", "DESIGNER_SUPPORTED"} +_KEYS = _SINGLE | _LIST | _FLAGS + + +def version(value: str, *, optional_minor=False): + match = re.fullmatch(r"(\d{1,6})(?:\.(\d{1,6}))?", value) + if not match or (not optional_minor and match[2] is None): + return None + return int(match[1]), int(match[2]) if match[2] is not None else None + + +def uri(value: str) -> bool: + return bool(value) and all(part.isidentifier() for part in value.split(".")) + + +def _sections(tokens): + sections, active = {}, None + for token in tokens: + if token.value in _KEYS: + if token.value in sections: + return None + active = token.value + sections[active] = [] + elif active is None or active in _FLAGS: + return None + else: + sections[active].append(token) + if any(len(sections[key]) != 1 for key in sections if key in _SINGLE): + return None + return sections + + +def _module(facts, command): + start, end = facts.byte_offsets[command.start], facts.byte_offsets[command.end] + tokens = command.tokens + if command.scoped or any(token.dynamic for token in tokens): + facts.reject("conditional_or_expanded_qml_module", start, end) + return + sections = _sections(tokens[1:]) if tokens else None + if not sections or "URI" not in sections: + facts.reject("unsupported_qml_module_arguments", start, end) + return + name = sections["URI"][0].value + ver = version(sections["VERSION"][0].value) if "VERSION" in sections else (None, None) + if not uri(name) or ver is None: + facts.reject("invalid_module_uri_or_version", start, end) + return + target = tokens[0].value + key = hashlib.sha256(repr((facts.file, target, name)).encode()).hexdigest() + scalar = {key.lower(): values[0].value for key, values in sections.items() if key in _SINGLE} + module = facts.add("qt_module", (target, name), name, start, end, + uri=name, major=ver[0], minor=ver[1], target_name=target, module_key=key, + build_system="cmake", generated=False, + resource_prefix=scalar.get("resource_prefix"), + output_directory=scalar.get("output_directory"), + no_resource_target_path="NO_RESOURCE_TARGET_PATH" in sections) + for group, kind in (("QML_FILES", "qml"), ("SOURCES", "cpp"), ("RESOURCES", "resource")): + for token in sections.get(group, []): + facts.add_chars("qt_source", (key, kind, token.value), token.value, token.start, token.end, + owner=module, module_key=key, value=token.value, source_kind=kind) + for group in ("IMPORTS", "DEPENDENCIES", "OPTIONAL_IMPORTS", "DEFAULT_IMPORTS"): + if any(token.value == "TARGET" for token in sections.get(group, [])): + facts.reject("target_import_requires_policy_and_target_resolution", start, end) + continue + for token in sections.get(group, []): + parts = token.value.split("/") + dep_ver = version(parts[1], optional_minor=True) if len(parts) == 2 else (None, None) + auto = len(parts) == 2 and parts[1] == "auto" + if len(parts) > 2 or not uri(parts[0]) or (dep_ver is None and not auto): + facts.reject("unsupported_module_import", facts.byte_offsets[token.start], facts.byte_offsets[token.end]) + continue + facts.add_chars("qt_module_import", (key, group, token.value), parts[0], token.start, token.end, + owner=module, module_key=key, uri=parts[0], major=dep_ver[0] if dep_ver else None, + minor=dep_ver[1] if dep_ver else None, auto=auto, + visibility="import" if group == "IMPORTS" else group.lower()) + for token in sections.get("IMPORT_PATH", []): + facts.add_chars("qt_import_path", (key, token.value), token.value, token.start, token.end, + owner=module, module_key=key, value=token.value) + for option in ("PAST_MAJOR_VERSIONS",): + if option in sections: + facts.reject("past_major_versions_not_materialized", start, end) + + +def parse_cmake(source: bytes | str, *, relative_file="CMakeLists.txt") -> dict: + """Supported declarations are literal, flat and source-local, with exact spans.""" + try: + facts = ProjectFacts(source, relative_file, "cmake") + for command in commands(facts.text): + if command.name in {"qt_add_qml_module", "qt6_add_qml_module"}: + _module(facts, command) + elif command.name in {"qt_target_qml_sources", "target_sources", + "set_source_files_properties", "set_property"}: + # These can change the module's source/version/resource metadata. + facts.reject("source_mutation_outside_literal_subset", facts.byte_offsets[command.start], + facts.byte_offsets[command.end]) + return facts.result() + except (UnicodeError, ValueError) as exc: + return failure(relative_file, "cmake", exc) + + +def extract_cmake(path: Path, *, root: Path | None = None) -> dict: + return extract_project(path, root, "cmake", parse_cmake) diff --git a/graphify/extractors/qml_cmake_syntax.py b/graphify/extractors/qml_cmake_syntax.py new file mode 100644 index 0000000000..8d3d0ebe09 --- /dev/null +++ b/graphify/extractors/qml_cmake_syntax.py @@ -0,0 +1,103 @@ +"""Literal CMake tokenization with source positions and unevaluated scope barriers.""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from graphify.extractors.qml_project_read import MetadataError + + +@dataclass(frozen=True) +class Token: + value: str + start: int + end: int + dynamic: bool = False + + +@dataclass(frozen=True) +class Command: + name: str + tokens: tuple[Token, ...] + start: int + end: int + scoped: bool + + +def _bracket(text, offset): + match = re.match(r"\[(=*)\[", text[offset:]) + if not match: + return None + close = "]" + match[1] + "]" + start = offset + len(match[0]) + end = text.find(close, start) + if end < 0: + raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_bracket") + return start, end, end + len(close) + + +def commands(text: str): + """Yield flat commands; declarations inside control/function scopes stay scoped.""" + pos, scopes = 0, [] + while pos < len(text): + if text[pos].isspace() or (pos == 0 and text[pos] == "\ufeff"): + pos += 1 + continue + if text[pos] == "#": + block = _bracket(text, pos + 1) + pos = block[2] if block else text.find("\n", pos) + if pos < 0: + return + continue + match = re.match(r"([A-Za-z_][A-Za-z_0-9]*)\s*\(", text[pos:]) + if not match: + raise MetadataError("QML_CMAKE_SYNTAX", "unsupported_command_syntax") + name, start = match[1].lower(), pos + pos += len(match[0]) + tokens = [] + while pos < len(text) and text[pos] != ")": + if text[pos].isspace(): + pos += 1 + continue + if text[pos] == "#": + block = _bracket(text, pos + 1) + pos = block[2] if block else text.find("\n", pos) + if pos < 0: + raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_command") + continue + token_start = pos + block = _bracket(text, pos) + if block: + value, pos = text[block[0]:block[1]], block[2] + elif text[pos] == '"': + pos += 1 + while pos < len(text) and text[pos] != '"': + if text[pos] == "\\": + pos += 1 + pos += 1 + if pos >= len(text): + raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_quote") + value = text[token_start + 1:pos] + pos += 1 + else: + while pos < len(text) and not text[pos].isspace() and text[pos] != ")": + pos += 1 + value = text[token_start:pos] + tokens.append(Token(value, token_start, pos, + any(char in value for char in "$;\\()"))) + if len(tokens) > 10_000: + raise MetadataError("QML_PROJECT_LIMIT", "cmake_token_limit") + if pos >= len(text): + raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_command") + pos += 1 + if name in {"endif", "endforeach", "endwhile", "endfunction", "endmacro", "endblock"}: + expected = name.removeprefix("end") + if not scopes or scopes.pop() != expected: + raise MetadataError("QML_CMAKE_SYNTAX", "unbalanced_scope") + yield Command(name, tuple(tokens), start, pos, bool(scopes)) + if name in {"if", "foreach", "while", "function", "macro", "block"}: + scopes.append(name) + if len(scopes) > 64: + raise MetadataError("QML_PROJECT_LIMIT", "cmake_scope_limit") + if scopes: + raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_scope") diff --git a/graphify/extractors/qml_declarations.py b/graphify/extractors/qml_declarations.py new file mode 100644 index 0000000000..0232ee31a6 --- /dev/null +++ b/graphify/extractors/qml_declarations.py @@ -0,0 +1,157 @@ +"""Lexical QML declaration scopes; grouped properties are not instances.""" +from __future__ import annotations + +from graphify.extractors.qml_facts import FactBuilder, encode_metadata, field, make_scope_key, qml_metadata, span, text +from graphify.extractors.qml_literals import literal_fields + + +class Declarations: + def __init__(self, facts: FactBuilder): + self.facts = facts + self.source = facts.source + self.objects: list[tuple] = [] + self.members: list[tuple] = [] + + def extract(self, program): + facts = self.facts + file = facts.add("file", facts.path.name, program, empty=not bool(self.source.strip())) + singleton = any(child.type == "ui_pragma" and field(child, "name", self.source) == "Singleton" + for child in program.named_children) + for ordinal, syntax in enumerate(program.named_children): + if syntax.type == "ui_import": + value = field(syntax, "source", self.source) + quoted = value[:1] in ("'", '"') + version = syntax.child_by_field_name("version") + major = field(version, "major", self.source) if version else "" + minor = field(version, "minor", self.source) if version else "" + facts.add("import", value.strip("\"'"), syntax, file, + semantic_key=f"import:{ordinal}:{value}", + import_kind="script" if quoted and value.strip("\"'").endswith((".js", ".mjs")) else "directory" if quoted else "module", + value=value.strip("\"'"), qualifier=field(syntax, "alias", self.source) or None, + major=int(major) if major else None, minor=int(minor) if minor else None) + elif syntax.type == "ui_object_definition": + name = facts.path.name.removesuffix(".ui.qml").removesuffix(".qml") + component_key = make_scope_key(facts.relative_file, name) + component = facts.add("component", name, syntax, file, + component_key=component_key, type_name=field(syntax, "type_name", self.source), + singleton=singleton) + self.object(syntax, component, component_key, "", 0) + self._construction_mutations() + return file + + def object(self, syntax, owner, component_key, parent_scope, ordinal, context_key=""): + facts = self.facts + type_name = field(syntax, "type_name", self.source) + initializer = syntax.child_by_field_name("initializer") + object_id = "" + for child in initializer.named_children if initializer else []: + if child.type == "ui_binding" and field(child, "name", self.source) == "id": + value = child.child_by_field_name("value") + if value and value.named_children and value.named_children[0].type == "identifier": + object_id = text(value.named_children[0], self.source) + group = bool(type_name) and type_name.split(".")[0][:1].islower() + kind = "property_group" if group else "object" + slot = make_scope_key(facts.relative_file, context_key) if context_key else "" + anchor = f"{component_key}:{parent_scope}:{slot}:{kind}:{object_id or type_name + ':' + str(ordinal)}" + scope = make_scope_key(facts.relative_file, anchor) + node = facts.add(kind, type_name if group else object_id or type_name, syntax, owner, + semantic_key=anchor, component_key=component_key, + object_scope_key=scope, parent_scope_key=parent_scope, + construction_parent_scope_key=parent_scope if not group else "", + construction_parent_kind=("property" if qml_metadata(owner).get("kind") == "property" else + "binding" if context_key.startswith("binding:") else "default") if parent_scope else "root", + type_name=type_name, object_id=object_id) + self.objects.append((syntax, node)) + # Component templates encapsulate their body IDs. Runtime creation + # context may flow inward; lexical proximity does not prove that context. + boundary = type_name.rsplit(".", 1)[-1] == "Component" + child_component = make_scope_key(facts.relative_file, f"{component_key}:body:{scope}") if boundary else component_key + child_parent = "" if boundary else scope + for index, child in enumerate(initializer.named_children if initializer else []): + if child.type == "ui_object_definition": + self.object(child, node, child_component, child_parent, index) + elif child.type == "ui_binding": + value = child.child_by_field_name("value") + name = field(child, "name", self.source) + if value and value.type in {"ui_object_definition", "ui_object_array"}: + template = name.split(".")[-1] in {"delegate", "sourceComponent"} + binding_component = make_scope_key(facts.relative_file, f"{component_key}:template:{scope}:{name}") if template else child_component + self.object_value(value, node, binding_component, "" if template else child_parent, f"binding:{name}") + elif child.type == "ui_inline_component": + name = field(child, "name", self.source) + nested_key = make_scope_key(facts.relative_file, f"{component_key}:inline:{name}") + inline = facts.add("inline_component", name, child, node, + semantic_key=f"{component_key}:inline:{name}", component_key=nested_key, + enclosing_component_key=component_key, parent_scope_key=scope) + nested = child.child_by_field_name("component") + if nested: + self.object(nested, inline, nested_key, "", 0) + elif child.type in ("ui_property", "ui_signal", "function_declaration", "enum_declaration"): + self.member(child, node, component_key, scope) + + def member(self, syntax, owner, component_key, scope): + facts = self.facts + kind = {"ui_property": "property", "ui_signal": "signal", + "function_declaration": "function", "enum_declaration": "enum"}[syntax.type] + name = field(syntax, "name", self.source) + parameters = syntax.child_by_field_name("parameters") + parameter_names = ([field(parameter, "name", self.source) for parameter in parameters.named_children] + if kind == "signal" and parameters else []) + if len(parameter_names) > 50: + # The graph metadata sanitizer caps lists at 50. Reject instead of + # losing an authoritative implicit signal binder during persistence. + raise ValueError("QML_LIMIT: signal parameter count exceeds supported 50") + modifiers = [text(c, self.source) for c in syntax.named_children if c.type == "ui_property_modifier"] + node = facts.add(kind, name, syntax, owner, semantic_key=f"{scope}:{kind}:{name}", + component_key=component_key, object_scope_key=scope, + parent_scope_key=scope, raw_type=field(syntax, "type", self.source), + modifiers=modifiers, signature=text(parameters, self.source), + return_type=field(syntax, "return_type", self.source), parameter_names=parameter_names, + **(literal_fields(syntax.child_by_field_name("value"), self.source) if kind == "property" else {})) + self.members.append((syntax, node)) + value = syntax.child_by_field_name("value") + if value and value.type in {"ui_object_definition", "ui_object_array"}: + template = field(syntax, "type", self.source).rsplit(".", 1)[-1] == "Component" + nested_component = make_scope_key(facts.relative_file, f"{component_key}:property:{node['id']}") if template else component_key + self.object_value(value, node, nested_component, "" if template else scope, node["id"]) + + def object_value(self, value, owner, component, scope, slot): + objects = value.named_children if value.type == "ui_object_array" else [value] + for ordinal, syntax in enumerate(objects): + if syntax.type == "ui_object_definition": + self.object(syntax, owner, component, scope, ordinal, context_key=slot) + + def _construction_mutations(self): + """Creation ownership is separate from visual parent and runtime parenting. + + Qt's non-widget object creator supplies a parent for nested creations, + including property values. It does not instantiate Component templates. + Explicit parent bindings/writes make that bounded tree uncertain; retain + their original evidence without evaluating a binding or JavaScript. + """ + mutations = {} + for syntax, node in self.objects: + component = qml_metadata(node)["component_key"] + initializer = syntax.child_by_field_name("initializer") + pending = list(initializer.named_children) if initializer else [] + while pending: + child = pending.pop() + if child.type in {"ui_object_definition", "ui_inline_component"}: + continue + target = child.child_by_field_name("left") or child.child_by_field_name("argument") + changed = child.type == "ui_binding" and field(child, "name", self.source).split(".")[-1] == "parent" + if child.type in {"assignment_expression", "augmented_assignment_expression", "update_expression"} and target: + changed = (target.type == "identifier" and text(target, self.source) == "parent" or + target.type == "member_expression" and field(target, "property", self.source) == "parent" or + target.type == "subscript_expression") + if changed: + evidence = mutations.setdefault(component, []) + evidence.append(span(child)) + if len(evidence) > 50: + raise ValueError("QML_LIMIT: construction mutation evidence exceeds supported 50") + pending.extend(child.named_children) + for _, node in self.objects: + md = qml_metadata(node) + if md["component_key"] in mutations: + node["metadata"]["qml"] = encode_metadata({**md, "construction_parent_dynamic": True, + "construction_parent_mutation_spans": mutations[md["component_key"]]}) diff --git a/graphify/extractors/qml_expressions.py b/graphify/extractors/qml_expressions.py new file mode 100644 index 0000000000..ff1a1398d6 --- /dev/null +++ b/graphify/extractors/qml_expressions.py @@ -0,0 +1,228 @@ +"""Source-owned QML bindings, subscriptions and JS use sites; never raw_calls.""" +from __future__ import annotations + +import hashlib + +from graphify.extractors.qml_facts import field, qml_metadata +from graphify.extractors.qml_literals import literal_fields +from graphify.extractors.qml_js_scopes import FUNCTIONS, bound_names, hoisted_names, local_declarations, qualified_name, reassigned_names + + +class ExpressionCollector: + """Every occurrence has its own owner/span so simple graph edges cannot collide.""" + + def __init__(self, facts): + self.facts, self.source = facts, facts.source + self.functions = {} + + def add(self, kind, syntax, owner, name, **values): + md = qml_metadata(owner) + key = hashlib.sha256(f'{owner["id"]}:{kind}:{syntax.start_byte}:{syntax.end_byte}'.encode()).hexdigest() + node = self.facts.add(kind, name, syntax, owner, semantic_key=key, + component_key=md.get("component_key", ""), + object_scope_key=md.get("object_scope_key", ""), + owner_id=owner["id"], script_file=md.get("script_file", ""), **values) + # Generic global-label resolution must not mistake an occurrence/local + # function for a shared JavaScript definition. This scoped resolver owns it. + node["label"] = f"QML site: {kind} {name}" + return node + + def function(self, syntax, owner, name): + key = syntax.start_byte, owner["id"] + if key not in self.functions: + self.functions[key] = self.add("js_function", syntax, owner, name) + return self.functions[key] + + def function_body(self, syntax, owner, environment=None): + body = syntax.child_by_field_name("body") + env = dict(environment or {}) + env.update({name: "" for name in bound_names(syntax.child_by_field_name("parameters") + or syntax.child_by_field_name("parameter"), self.source)}) + env.update({name: "" for name in hoisted_names(body, self.source)}) + name = field(syntax, "name", self.source) + if name: + env[name] = owner["id"] + self.scan(body, owner, env) + + def scan(self, syntax, owner, environment=None): + if syntax is None: + return + env = dict(environment or {}) + kind = syntax.type + if kind == "statement_block": + for name, function in local_declarations(syntax, self.source): + env[name] = self.function(function, owner, name)["id"] if function else "" + for name in reassigned_names(syntax, self.source): + if name in env: + env[name] = "" + if kind in {"for_statement", "for_in_statement"}: + for child in syntax.named_children: + if child.type == "lexical_declaration": + for declaration in child.named_children: + env.update({name: "" for name in bound_names(declaration.child_by_field_name("name"), self.source)}) + if kind == "for_in_statement": + if syntax.child_by_field_name("kind") is not None: + env.update({name: "" for name in bound_names(syntax.child_by_field_name("left"), self.source)}) + self.scan(syntax.child_by_field_name("right"), owner, env) + self.scan(syntax.child_by_field_name("body"), owner, env) + return + if kind in FUNCTIONS: + name = field(syntax, "name", self.source) or "anonymous" + function = self.function(syntax, owner, name) + self.function_body(syntax, function, env) + return + if kind in {"variable_declarator", "required_parameter", "optional_parameter"}: + self.scan(syntax.child_by_field_name("value"), owner, env) + return + if kind == "catch_clause": + env.update({name: "" for name in bound_names(syntax.child_by_field_name("parameter"), self.source)}) + if kind == "call_expression": + callee = syntax.child_by_field_name("function") + # A literal signal.connect callback is source AST provenance. The + # context resolver never re-reads QML or evaluates an argument. + callback = {} + reference = qualified_name(callee, self.source) or "" + arguments = syntax.child_by_field_name("arguments") + args = [item for item in arguments.named_children if item.type != "comment"] if arguments else [] + if reference.endswith(".connect"): + name = qualified_name(args[0], self.source) if len(args) == 1 else None + first = (name or "").split(".")[0] + callback = {"callback_reference": name or "", "callback_argument_count": len(args), + "callback_lexical_shadowed": first in env, + "callback_lexical_target_id": env.get(first, "") if name == first else ""} + self.use("call", callee, syntax, owner, env, **callback) + if qualified_name(callee, self.source) is None: + self.scan(callee, owner, env) + for argument in (syntax.child_by_field_name("arguments").named_children + if syntax.child_by_field_name("arguments") else []): + self.scan(argument, owner, env) + return + if kind in {"member_expression", "subscript_expression"}: + self.use("read", syntax, syntax, owner, env) + if qualified_name(syntax.child_by_field_name("object"), self.source) is None: + self.scan(syntax.child_by_field_name("object"), owner, env) + if kind == "subscript_expression": + self.scan(syntax.child_by_field_name("index"), owner, env) + return + if kind in {"identifier", "shorthand_property_identifier"}: + self.use("read", syntax, syntax, owner, env) + return + if kind in {"assignment_expression", "augmented_assignment_expression"}: + left = syntax.child_by_field_name("left") + if kind == "augmented_assignment_expression": + self.scan(left, owner, env) + elif left and left.type in {"member_expression", "subscript_expression"}: + # Computing a write address reads its receiver/index; it does + # not read the property value being overwritten. + self.scan(left.child_by_field_name("object"), owner, env) + self.scan(left.child_by_field_name("index"), owner, env) + self.scan(syntax.child_by_field_name("right"), owner, env) + return + if kind in {"comment", "string", "type_annotation", "type_identifier", "ui_object_definition", "ui_object_initializer"}: + return + for child in syntax.named_children: + self.scan(child, owner, env) + + def use(self, kind, target, syntax, owner, env, **fields): + reference = qualified_name(target, self.source) + first = (reference or "").split(".")[0] + self.add(kind, syntax, owner, reference or "dynamic", reference=reference or "", + lexical_names=sorted(env)[:50], lexical_shadowed=first in env, + lexical_target_id=env.get(first, "") if reference == first else "", + status="dynamic" if reference is None else "pending", + reason="computed_or_runtime_target" if reference is None else "", **fields) + + +def _connection_target(syntax, source): + initializer = syntax.child_by_field_name("initializer") + for child in initializer.named_children if initializer else []: + if child.type == "ui_binding" and field(child, "name", source) == "target": + value = child.child_by_field_name("value") + value = value.named_children[0] if value and value.type == "expression_statement" else value + return qualified_name(value, source) or "", True + return "", False + + +def _signal_parameters(declarations, owner, signal_name, connection_target): + """Source-declared implicit signal parameters participate in JS shadowing.""" + md = qml_metadata(owner) + scope = md.get("object_scope_key") + if connection_target: + objects = [node for _, node in declarations.objects if qml_metadata(node).get("object_id") == connection_target + and qml_metadata(node).get("component_key") == md.get("component_key")] + scope = qml_metadata(objects[0]).get("object_scope_key") if len(objects) == 1 else None + for syntax, node in declarations.members: + member = qml_metadata(node) + if member.get("kind") == "signal" and member.get("raw_name") == signal_name and member.get("object_scope_key") == scope: + parameters = syntax.child_by_field_name("parameters") + return {field(p, "name", declarations.source): "" for p in parameters.named_children} if parameters else {} + return {} + + +def collect_relationships(declarations) -> None: + """Collect facts only; project lookup is a separate run-scoped resolver.""" + collector = ExpressionCollector(declarations.facts) + members = {(qml_metadata(node).get("object_scope_key"), qml_metadata(node)["raw_name"]): node + for _, node in declarations.members} + objects = {node["id"]: syntax for syntax, node in declarations.objects} + owners = {qml_metadata(node).get("object_scope_key"): node for _, node in declarations.objects} + for syntax, owner in declarations.objects: + initializer = syntax.child_by_field_name("initializer") + for child in initializer.named_children if initializer else []: + if child.type != "ui_binding" or field(child, "name", collector.source) == "id": + continue + name, value = field(child, "name", collector.source), child.child_by_field_name("value") + if value is None or value.type in {"ui_object_definition", "ui_object_array"}: + continue + short = name.split(".")[-1] + if short.startswith("on") and len(short) > 2 and short[2].isupper(): + _handler(declarations, collector, child, owner, name, value, syntax) + else: + member = members.get((qml_metadata(owner).get("object_scope_key"), name), owner) + binding = collector.add("binding", child, member, name, reference=name, **literal_fields(value, collector.source)) + collector.scan(value, binding) + for syntax, member in declarations.members: + md = qml_metadata(member) + owner = owners.get(md.get("object_scope_key"), member) + name = md["raw_name"] + if md["kind"] == "property": + value = syntax.child_by_field_name("value") + if value is None or value.type == "ui_object_definition": + continue + if md.get("raw_type") == "alias": + expression = value.named_children[0] if value.type == "expression_statement" and value.named_children else value + reference = qualified_name(expression, collector.source) + collector.add("alias", value, member, name, reference=reference or "", + status="pending" if reference else "dynamic", reason="" if reference else "alias_not_static") + else: + binding = collector.add("binding", value, member, name, **literal_fields(value, collector.source)) + collector.scan(value, binding) + elif md["kind"] == "function": + if qml_metadata(owner).get("type_name") == "Connections" and name.startswith("on"): + _handler(declarations, collector, syntax, owner, name, syntax.child_by_field_name("body"), objects[owner["id"]], syntax) + else: + collector.function_body(syntax, member) + + +def _handler(declarations, collector, syntax, owner, name, body, object_syntax, function=None): + short = name.split(".")[-1] + signal = short[2:3].lower() + short[3:] + connections = qml_metadata(owner).get("type_name") == "Connections" + target, supplied = _connection_target(object_syntax, collector.source) if connections else ("", False) + initializer = object_syntax.child_by_field_name("initializer") + legacy = any(child.type == "ui_binding" and field(child, "name", collector.source).startswith("on") + and field(child, "name", collector.source)[2:3].isupper() + for child in initializer.named_children) if initializer else False + # Only legacy code blocks inherit signal declaration names. Function and + # arrow handlers bind their own formals, which need not use those names. + implicit_parameters = function is None and body is not None and body.type == "statement_block" + handler = collector.add("handler", syntax, owner, name, reference=signal, + connection_target=target, connection_supplied=supplied, + connections=connections, attached_prefix=name[:-len(short)].rstrip("."), + implicit_parameters=implicit_parameters, + disabled_reason="connections_mixed_handler_styles" if connections and function and legacy else "") + env = _signal_parameters(declarations, owner, signal, target) if implicit_parameters else {} + if function: + collector.function_body(function, handler, env) + else: + collector.scan(body, handler, env) diff --git a/graphify/extractors/qml_facts.py b/graphify/extractors/qml_facts.py new file mode 100644 index 0000000000..4994ee7d9c --- /dev/null +++ b/graphify/extractors/qml_facts.py @@ -0,0 +1,128 @@ +"""Portable, bounded QML facts and lossless semantic metadata transport.""" +from __future__ import annotations + +import base64 +import hashlib +import json +from collections.abc import Mapping +from pathlib import Path + +from graphify.ids import make_id +from graphify.extractors.qml_source_identity import SourceInputIdentityError, relative_qml_source + +CONTRACT_VERSION = 1 + + +def make_qml_id(relative_file: str, kind: str, semantic_key: str) -> str: + identity = json.dumps([relative_file, kind, semantic_key], ensure_ascii=False) + digest = hashlib.sha256(identity.encode()).hexdigest()[:16] + return make_id("qml", relative_file, kind, semantic_key, digest) + + +def make_scope_key(relative_file: str, semantic_key: str) -> str: + """Fixed-width scopes avoid multiplying parent ID length with nesting.""" + identity = json.dumps([relative_file, semantic_key], ensure_ascii=False) + return hashlib.sha256(identity.encode()).hexdigest() + + +def span(node) -> dict: + return { + "start_byte": node.start_byte, "end_byte": node.end_byte, + "start_row": node.start_point.row, "start_column": node.start_point.column, + "end_row": node.end_point.row, "end_column": node.end_point.column, + } + + +def text(node, source: bytes) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8") if node else "" + + +def field(node, name: str, source: bytes) -> str: + return text(node.child_by_field_name(name), source) + + +def qml_metadata(node: dict) -> dict: + """Read literal lookup values after graph sanitation/JSON round trips. + + Display values retain Graphify's HTML escaping. Their bounded base64 copies + preserve semantic punctuation without trusting HTML as a lookup encoding. + """ + meta = dict(node.get("metadata", {}).get("qml", {})) + raw = meta.get("raw_values", {}) + if not isinstance(raw, Mapping) or len(raw) > 50: + raise ValueError("QML_METADATA: invalid literal transport mapping") + for key, value in raw.items(): + if not isinstance(key, str) or not isinstance(value, str) or not isinstance(meta.get(key), str): + raise ValueError("QML_METADATA: invalid literal transport field") + if len(value) > 512: + raise ValueError("QML_LIMIT: literal transport exceeds its encoded bound") + try: + meta[key] = base64.b64decode(value, validate=True).decode("utf-8") + except (ValueError, UnicodeError) as exc: + raise ValueError("QML_METADATA: invalid literal transport encoding") from exc + return meta + + +def encode_metadata(values: dict) -> dict: + raw = {} + for key, value in values.items(): + if isinstance(value, str): + encoded = base64.b64encode(value.encode()).decode("ascii") + if len(encoded) > 512: + raise ValueError("QML_LIMIT: semantic field exceeds transport bound") + raw[key] = encoded + return {"contract_version": CONTRACT_VERSION, **values, "raw_values": raw} + + +class FactBuilder: + """One source owns declarations and derived sites; roots never enter IDs.""" + + def __init__(self, path: Path, root: Path | None, source: bytes): + self.path = path + self.source = source + try: + # Fact IDs use the admitted walked owner; physical target identity + # still governs containment and later resolution authority. + self.relative_file = relative_qml_source(path, root) + # QML file/component names are also identity inputs. Native short + # leaf spelling must not survive after its source owner is admitted. + self.path = path.with_name(Path(self.relative_file).name) + except SourceInputIdentityError: + raise + except ValueError as exc: + raise ValueError("QML_ROOT: source is outside the explicit scan root") from exc + self.nodes: list[dict] = [] + self.edges: list[dict] = [] + self.occurrences: dict[str, int] = {} + + def add(self, kind: str, name: str, syntax_node, owner=None, **values) -> dict: + key = values.pop("semantic_key", name) + identity = make_qml_id(self.relative_file, kind, key) + occurrence = self.occurrences.get(identity, 0) + self.occurrences[identity] = occurrence + 1 + if occurrence: + # Conflicting declarations retain separate evidence for ambiguity; + # valid named declarations keep identities independent of line spans. + identity = make_qml_id(self.relative_file, kind, f"{key}:duplicate:{occurrence}") + values["duplicate_declaration"] = True + meta = encode_metadata({"kind": kind, "raw_name": name, + "raw_type": "", "span": span(syntax_node), **values}) + node = {"id": identity, "label": name or kind, "file_type": "code", "_origin": "ast", + "type": {"file": "file", "component": "class", + "inline_component": "class", "function": "function", + "property": "property"}.get(kind, "concept"), + "source_file": self.relative_file, + "source_location": f"L{syntax_node.start_point.row + 1}-L{syntax_node.end_point.row + 1}", + "metadata": {"qml": meta}} + self.nodes.append(node) + if owner: + self.edge(owner["id"] if isinstance(owner, dict) else owner, + identity, "contains", syntax_node, kind="declaration") + return node + + def edge(self, source: str, target: str, relation: str, syntax_node, **values): + self.edges.append({"source": source, "target": target, "relation": relation, + "_src": source, "_tgt": target, + "confidence": "EXTRACTED", "source_file": self.relative_file, + "source_location": f"L{syntax_node.start_point.row + 1}-L{syntax_node.end_point.row + 1}", + "metadata": {"qml": encode_metadata({"span": span(syntax_node), **values})}}) diff --git a/graphify/extractors/qml_js_scopes.py b/graphify/extractors/qml_js_scopes.py new file mode 100644 index 0000000000..76692b3718 --- /dev/null +++ b/graphify/extractors/qml_js_scopes.py @@ -0,0 +1,82 @@ +"""Lexical JavaScript bindings for QML subtrees; no value evaluation/dataflow.""" +from __future__ import annotations + +from graphify.extractors.qml_facts import text + +FUNCTIONS = {"function_declaration", "function_expression", "arrow_function", "generator_function_declaration"} + + +def bound_names(node, source: bytes) -> set[str]: + """Collect binding positions only; object keys and type names are not locals.""" + if node is None: + return set() + if node.type in {"identifier", "shorthand_property_identifier_pattern"}: + return {text(node, source)} + if node.type in {"required_parameter", "optional_parameter", "assignment_pattern", "pair_pattern"}: + child = (node.child_by_field_name("pattern") or node.child_by_field_name("left") + or node.child_by_field_name("value")) + return bound_names(child, source) + return set().union(*(bound_names(c, source) for c in node.named_children + if c.type not in {"type_annotation", "type_identifier", "property_identifier"})) + + +def qualified_name(node, source: bytes) -> str | None: + """Accept a literal identifier/member chain; computed accesses stay dynamic.""" + if node is None: + return None + if node.type in {"identifier", "property_identifier", "nested_identifier"}: + value = text(node, source) + return value if len(value) <= 256 else None + if node.type == "member_expression": + receiver = qualified_name(node.child_by_field_name("object"), source) + member = node.child_by_field_name("property") + if receiver and member and member.type == "property_identifier": + value = receiver + "." + text(member, source) + return value if len(value) <= 256 else None + return None + + +def hoisted_names(body, source: bytes) -> set[str]: + """var binds the function; nested functions and their vars remain isolated.""" + names, pending = set(), [body] + while pending: + node = pending.pop() + if node is None or node.type in FUNCTIONS: + continue + if node.type == "variable_declaration": + for child in node.named_children: + if child.type == "variable_declarator": + names.update(bound_names(child.child_by_field_name("name"), source)) + if node.type == "for_in_statement" and text(node.child_by_field_name("kind"), source) == "var": + names.update(bound_names(node.child_by_field_name("left"), source)) + pending.extend(node.named_children) + return names + + +def local_declarations(block, source: bytes): + """Block-level let/const/function bindings shadow only their lexical block.""" + for child in block.named_children: + if child.type == "function_declaration": + yield text(child.child_by_field_name("name"), source), child + elif child.type in {"lexical_declaration", "variable_declaration"}: + for declaration in child.named_children: + if declaration.type != "variable_declarator": + continue + value = declaration.child_by_field_name("value") + for name in bound_names(declaration.child_by_field_name("name"), source): + yield name, value if value and value.type in FUNCTIONS else None + + +def reassigned_names(body, source: bytes) -> set[str]: + """A reassigned callable has runtime value/dataflow; don't reuse its initializer.""" + names, pending = set(), [body] + while pending: + node = pending.pop() + if node is None or node.type in FUNCTIONS: + continue + if node.type in {"assignment_expression", "augmented_assignment_expression", "update_expression"}: + target = node.child_by_field_name("left") or node.child_by_field_name("argument") + if target and target.type == "identifier": + names.add(text(target, source)) + pending.extend(node.named_children) + return names diff --git a/graphify/extractors/qml_literals.py b/graphify/extractors/qml_literals.py new file mode 100644 index 0000000000..76ecbae90a --- /dev/null +++ b/graphify/extractors/qml_literals.py @@ -0,0 +1,17 @@ +"""Plain source literal evidence for static C++ objectName/property joins.""" +from __future__ import annotations + +import json + + +def literal_fields(syntax, source): + if syntax and syntax.type == "expression_statement" and len(syntax.named_children) == 1: + syntax = syntax.named_children[0] + if syntax is None or syntax.type != "string": + return {} + value = source[syntax.start_byte:syntax.end_byte].decode() + try: + decoded = json.loads(value) if value.startswith('"') else value[1:-1] if value.startswith("'") and "\\" not in value else None + except ValueError: + return {} + return {"literal_value": decoded} if isinstance(decoded, str) and len(decoded.encode()) <= 256 else {} diff --git a/graphify/extractors/qml_metadata.py b/graphify/extractors/qml_metadata.py new file mode 100644 index 0000000000..30737ab62e --- /dev/null +++ b/graphify/extractors/qml_metadata.py @@ -0,0 +1,181 @@ +"""Bounded, source-only qmldir reader with independently owned metadata records.""" + +from __future__ import annotations + +import re +import base64 +from pathlib import Path + +from graphify.qml_resolution_types import fact_edge, fact_node +from graphify.extractors.qml_source_identity import SourceInputIdentityError, relative_qml_source + +MAX_METADATA_BYTES = 1_048_576 +MAX_METADATA_RECORDS = 10_000 +_VERSION = re.compile(r"^(\d{1,6})\.(\d{1,6})$") + + +def _version(value: str) -> tuple[int, int] | None: + match = _VERSION.fullmatch(value) + return (int(match[1]), int(match[2])) if match else None + + +def _identifier(value: str) -> bool: + return all(part.isidentifier() for part in value.split(".")) + + +def parse_qmldir(text: str, *, relative_file: str = "qmldir") -> dict: + """Parse supported literal directives; unsupported coverage is never success.""" + file_node = fact_node(relative_file, "file", "qmldir", Path(relative_file).name, 1, + metadata_format="qmldir", span={ + "start_byte": 0, "end_byte": len(text.encode("utf-8")), + "start_row": 0, "start_column": 0, + "end_row": text.count("\n"), + "end_column": len(text.rsplit("\n", 1)[-1].encode("utf-8"))}) + nodes, edges, failures = [file_node], [], [] + module_node = None + occurrences: dict[tuple, int] = {} + exports: set[tuple] = set() + + # Each record owns its data, avoiding the export-list cap in metadata rendering. + def record(kind, key, label, line, **fields): + count = occurrences.get((kind, key), 0) + occurrences[kind, key] = count + 1 + node = fact_node(relative_file, kind, (key, count), label, line, + span=line_spans[line], **fields) + nodes.append(node) + parent = module_node or file_node + edges.append(fact_edge(parent["id"], node["id"], "contains", node, + "qml_metadata_declaration", span=line_spans[line])) + return node + + def fail(line, reason): + if len(failures) < 50: + failures.append({"line": line, "code": "QML-META-001", "reason": reason}) + + if len(text.encode("utf-8")) > MAX_METADATA_BYTES: + fail(1, "metadata_size_limit") + text = "" + # UTF-8 byte offsets remain attached to each admitted directive. + line_spans, offset = {}, 0 + source_lines = text.splitlines(keepends=True) + for line, raw in enumerate(source_lines, 1): + body = raw.rstrip("\r\n") + prefix_bytes = 3 if line == 1 and body.startswith("\ufeff") else 0 + end = offset + len(body.encode("utf-8")) + line_spans[line] = {"start_byte": offset + prefix_bytes, "end_byte": end, + "start_row": line - 1, "end_row": line - 1, + "start_column": prefix_bytes, + "end_column": len(body.encode("utf-8"))} + offset += len(raw.encode("utf-8")) + meaningful = 0 + for line, raw in enumerate(text.splitlines(), 1): + if line == 1: + raw = raw.removeprefix("\ufeff") + value = raw.strip() + if not value or value.startswith("#"): + continue + meaningful += 1 + if meaningful > MAX_METADATA_RECORDS: + fail(line, "metadata_record_limit") + break + tokens = value.split() + if any(len(base64.b64encode(token.encode("utf-8"))) > 512 or + any(ord(char) < 32 for char in token) for token in tokens): + fail(line, "metadata_value_limit") + continue + head = tokens[0] + if head == "module": + if len(tokens) != 2 or not _identifier(tokens[1]) or module_node or meaningful != 1: + fail(line, "invalid_module_declaration") + continue + module_node = record("module", tokens[1], tokens[1], line, uri=tokens[1], + raw_name=tokens[1]) + continue + if head in {"depends", "import"}: + if len(tokens) not in {2, 3} or not _identifier(tokens[1]): + fail(line, "invalid_module_dependency") + continue + version = _version(tokens[2]) if len(tokens) == 3 else None + auto = len(tokens) == 3 and tokens[2] == "auto" and head == "import" + if len(tokens) == 3 and not version and not auto: + fail(line, "invalid_module_version") + continue + record("module_import" if head == "import" else "dependency", tuple(tokens), + tokens[1], line, uri=tokens[1], major=version[0] if version else None, + minor=version[1] if version else None, auto=auto) + continue + if head in {"plugin", "optional", "classname", "typeinfo", "prefer", + "designersupported"}: + directive = "plugin" if tokens[:2] == ["optional", "plugin"] else head + args = tokens[2:] if tokens[:2] == ["optional", "plugin"] else tokens[1:] + valid = ((directive == "plugin" and len(args) in {1, 2}) or + (directive in {"classname", "typeinfo", "prefer"} and len(args) == 1) or + (directive == "designersupported" and not args)) + if not valid: + fail(line, "unsupported_or_malformed_directive") + continue + record(directive, tuple(tokens), directive, line, value=args[0] if args else "", + optional=head == "optional", path=args[1] if len(args) > 1 else "") + continue + flag = head if head in {"singleton", "internal"} else "" + entry = tokens[1:] if flag else tokens + version = _version(entry[1]) if len(entry) == 3 else None + valid = ((len(entry) == 2 and flag != "singleton") or + (len(entry) == 3 and version and flag != "internal")) + if not valid or not entry[0].isidentifier() or not entry[-1].endswith((".qml", ".js", ".mjs")): + fail(line, "unsupported_or_malformed_directive") + continue + export_key = (entry[0], version, flag == "internal") + if export_key in exports: + fail(line, "duplicate_export") + exports.add(export_key) + record("export", (entry[0], version, entry[-1], flag), entry[0], line, + raw_name=entry[0], target_path=entry[-1], + major=version[0] if version else None, minor=version[1] if version else None, + internal=flag == "internal", singleton=flag == "singleton", + export_kind="script" if entry[-1].endswith((".js", ".mjs")) else "type") + result = {"nodes": nodes, "edges": edges, "diagnostics": []} + if failures: + result.update(error="QML-META-001: incomplete qmldir metadata; inspect diagnostics", + partial=True, parse_errors=failures) + result["diagnostics"] = [{"code": item["code"], "severity": "error", + "owner": "qmldir", "source_file": relative_file, + "line": item["line"], "reason": item["reason"], + "message": "Unsupported or malformed qmldir metadata", + "recovery": "Correct the directive or use the documented literal subset."} + for item in failures] + return result + + +def extract_qmldir(path: Path, *, root: Path | None = None) -> dict: + """Read one admitted file; do not follow any paths or plugins it declares.""" + path = Path(path) + relative = "" + try: + # Admission stays physical; source facts and failure context retain the + # separately discovered lexical owner before original bytes are read. + relative = relative_qml_source(path, root) + if path.stat().st_size > MAX_METADATA_BYTES: + return _read_failure(relative, "metadata_size_limit") + with path.open("rb") as stream: + raw = stream.read(MAX_METADATA_BYTES + 1) + if len(raw) > MAX_METADATA_BYTES: + return _read_failure(relative, "metadata_size_limit") + # Keep BOM and CRLF bytes in provenance; only tokenization skips a BOM. + text = raw.decode("utf-8") + except SourceInputIdentityError as exc: + return _read_failure("", exc.reason, code=exc.code) + except (OSError, RuntimeError, UnicodeError, ValueError): + return _read_failure(relative, "metadata_unreadable_or_outside_root") + return parse_qmldir(text, relative_file=relative) + + +def _read_failure(relative: str, reason: str, *, code="QML-META-002") -> dict: + identity_failed = code == "SOURCE_INPUT_IDENTITY_FAILED" + return {"nodes": [], "edges": [], "error": f"{code}: metadata read rejected", + "diagnostics": [{"code": code, "severity": "error", "owner": "qmldir", + "source_file": relative, "reason": reason, + "message": "Source input identity unavailable" if identity_failed else + "Metadata unreadable, oversized or outside the scan root", + "recovery": "Repair filesystem access and retry." if identity_failed else + "Use an accepted UTF-8 qmldir within the documented limits."}]} diff --git a/graphify/extractors/qml_project_read.py b/graphify/extractors/qml_project_read.py new file mode 100644 index 0000000000..61cc346a58 --- /dev/null +++ b/graphify/extractors/qml_project_read.py @@ -0,0 +1,131 @@ +"""Bounded original-byte Qt metadata input and source-owned fact construction.""" +from __future__ import annotations + +import hashlib +import posixpath +from bisect import bisect_left +from pathlib import Path + +from graphify.qml_resolution_types import fact_edge, fact_node +from graphify.extractors.qml_source_identity import relative_qml_source + +MAX_PROJECT_BYTES = 1_048_576 +MAX_PROJECT_FACTS = 10_000 + + +class MetadataError(ValueError): + def __init__(self, code: str, reason: str): + self.code, self.reason = code, reason + super().__init__(f"{code}: {reason}") + + +def literal_path(source_file: str, value: str) -> str | None: + """Normalize a literal within the corpus without reading its target.""" + value = value.replace("\\", "/") + if not value or value.startswith("/") or ":" in value or "\x00" in value: + return None + path = posixpath.normpath(posixpath.join(posixpath.dirname(source_file), value)) + return None if path == ".." or path.startswith("../") else path + + +class ProjectFacts: + """One accepted metadata file owns records; no mutable project context exists.""" + def __init__(self, source: bytes | str, relative_file: str, metadata_format: str): + self.raw = source.encode("utf-8") if isinstance(source, str) else source + if len(self.raw) > MAX_PROJECT_BYTES: + raise MetadataError("QML_PROJECT_LIMIT", "metadata_size_limit") + self.text = self.raw.decode("utf-8") + self.file = relative_file + self.format = metadata_format + self.nodes: list[dict] = [] + self.edges: list[dict] = [] + self.diagnostics: list[dict] = [] + self.counts: dict[tuple, int] = {} + self.newlines = [pos for pos, char in enumerate(self.raw) if char == 10] + self.byte_offsets = [0] + for char in self.text: + self.byte_offsets.append(self.byte_offsets[-1] + len(char.encode("utf-8"))) + self.file_node = self.add("file", metadata_format, Path(relative_file).name, + 0, len(self.raw), metadata_format=metadata_format) + + def span(self, start: int, end: int) -> dict: + def point(offset): + row = bisect_left(self.newlines, offset) + return row, offset - (self.newlines[row - 1] + 1 if row else 0) + sr, sc = point(start) + er, ec = point(end) + return {"start_byte": start, "end_byte": end, "start_row": sr, + "start_column": sc, "end_row": er, "end_column": ec} + + def add(self, kind: str, key, label: str, start: int, end: int, *, owner=None, + **fields) -> dict: + if len(self.nodes) >= MAX_PROJECT_FACTS: + raise MetadataError("QML_PROJECT_LIMIT", "metadata_fact_limit") + digest = hashlib.sha256(repr((kind, key)).encode("utf-8")).hexdigest() + occurrence = self.counts.get((kind, digest), 0) + self.counts[kind, digest] = occurrence + 1 + location = self.span(start, end) + node = fact_node(self.file, kind, (digest, occurrence), label, + location["start_row"] + 1, span=location, **fields) + self.nodes.append(node) + parent = owner or getattr(self, "file_node", None) + if parent: + self.edges.append(fact_edge(parent["id"], node["id"], "contains", node, + "qt_metadata_declaration", span=location)) + return node + + def add_chars(self, kind, key, label, start, end, **fields): + return self.add(kind, key, label, self.byte_offsets[start], self.byte_offsets[end], **fields) + + def reject(self, reason: str, start=0, end=None, *, code="QML_PROJECT_UNSUPPORTED"): + if len(self.diagnostics) < 50: + self.diagnostics.append({"code": code, "reason": reason, "severity": "error", + "owner": self.format, "source_file": self.file, + "span": self.span(start, len(self.raw) if end is None else end), + "message": "Qt metadata coverage is incomplete", + "recovery": "Use the documented literal subset and retry."}) + + def result(self) -> dict: + result: dict = {"nodes": self.nodes, "edges": self.edges, "diagnostics": self.diagnostics} + if self.diagnostics: + result.update(error="QML_PROJECT_UNSUPPORTED: incomplete metadata coverage", partial=True, + qml_failures=[{"code": d["code"], "source_file": self.file, + "reason": d["reason"]} for d in self.diagnostics]) + return result + + +def failure(relative_file: str, metadata_format: str, error: Exception) -> dict: + code = getattr(error, "code", "QML_PROJECT_READ") + reason = getattr(error, "reason", "metadata_unreadable_or_outside_root") + identity_failed = code == "SOURCE_INPUT_IDENTITY_FAILED" + # Native admission failed before a source label was established. Match the + # other typed readers' explicit unavailable context, without a raw basename. + if identity_failed: + relative_file = "" + return {"nodes": [], "edges": [], "error": f"{code}: metadata rejected", + "diagnostics": [{"code": code, "reason": reason, "severity": "error", + "owner": metadata_format, "source_file": relative_file, + "message": "Source input identity unavailable" if identity_failed else + "Qt metadata rejected before source expansion", + "recovery": "Repair filesystem access and retry." if identity_failed else + "Use accepted UTF-8 metadata within documented bounds."}], + "qml_failures": [{"code": code, "source_file": relative_file, "reason": reason}]} + + +def extract_project(path: Path, root: Path | None, metadata_format: str, parser) -> dict: + """Read original bytes with stat/read bounds and an explicit source-root gate.""" + path = Path(path) + relative = path.name + try: + # Resolve authority before retaining a lexical source owner. Literal + # targets remain relative to that declaration; no new file is admitted. + relative = relative_qml_source(path, root) + if path.stat().st_size > MAX_PROJECT_BYTES: + raise MetadataError("QML_PROJECT_LIMIT", "metadata_size_limit") + with path.open("rb") as stream: + raw = stream.read(MAX_PROJECT_BYTES + 1) + if len(raw) > MAX_PROJECT_BYTES: + raise MetadataError("QML_PROJECT_LIMIT", "metadata_size_limit") + return parser(raw, relative_file=relative) + except (OSError, RuntimeError, UnicodeError, ValueError) as exc: + return failure(relative, metadata_format, exc) diff --git a/graphify/extractors/qml_qmake.py b/graphify/extractors/qml_qmake.py new file mode 100644 index 0000000000..b238204ba7 --- /dev/null +++ b/graphify/extractors/qml_qmake.py @@ -0,0 +1,75 @@ +"""Qt 6 qmake module facts with bounded current-file paths and lexical coverage.""" +from __future__ import annotations + +import hashlib +from pathlib import Path + +from graphify.extractors.qml_cmake import uri, version +from graphify.extractors.qml_project_read import ProjectFacts, extract_project, failure +from graphify.extractors.qml_qmake_values import assignments + + +def parse_qmake(source: bytes | str, *, relative_file="project.pro") -> dict: + try: + facts = ProjectFacts(source, relative_file, "qmake") + accepted = assignments(facts) + names = accepted.get("QML_IMPORT_NAME", []) + if len(names) > 1 or (names and not uri(names[0].value)): + facts.reject("invalid_module_uri") + if not names or facts.diagnostics: + _import_paths(facts, accepted, None, None) + return facts.result() + entry = names[0] + name, start, end = entry.value, entry.start, entry.end + ver_entries = accepted.get("QML_IMPORT_VERSION", []) + major, minor = accepted.get("QML_IMPORT_MAJOR_VERSION", []), accepted.get("QML_IMPORT_MINOR_VERSION", []) + if len(ver_entries) > 1 or len(major) > 1 or len(minor) > 1: + facts.reject("invalid_module_version") + return facts.result() + ver = version(ver_entries[0].value) if ver_entries else version( + major[0].value + "." + (minor[0].value if minor else "0")) if major else None + if ver is None or (ver_entries and (major or minor)): + facts.reject("missing_or_conflicting_module_version") + return facts.result() + targets = accepted.get("TARGET", []) + if len(targets) > 1: + facts.reject("invalid_target_name") + return facts.result() + target = targets[0].value if len(targets) == 1 else Path(relative_file).stem + key = hashlib.sha256(repr((relative_file, target, name)).encode()).hexdigest() + module = facts.add_chars("qt_module", (target, name), name, start, end, + uri=name, major=ver[0], minor=ver[1], target_name=target, + module_key=key, build_system="qmake", generated=False, + version_origin="literal_version" if ver_entries else + "literal_major_minor" if minor else "base_major_registration", + minor_upper_bound_known=bool(ver_entries or minor), + resource_prefix=None, no_resource_target_path=False) + # Accepted lexical paths stay relative to this metadata file. Project + # indexing joins only already admitted source facts; no disk discovery. + for group, kind in (("SOURCES", "cpp"), ("HEADERS", "cpp"), ("QML_FILES", "qml"), + ("DISTFILES", "distribution"), ("RESOURCES", "qrc")): + for entry in accepted.get(group, []): + facts.add_chars("qt_source", (key, kind, entry.value), entry.value, + entry.start, entry.end, owner=module, + module_key=key, value=entry.value, source_kind=kind, + source_value=entry.source_value, path_origin=entry.path_origin) + # Tooling hints and build scanner roots are separate facts. Neither is + # an application runtime search path or an implicit analysis root. + _import_paths(facts, accepted, module, key) + return facts.result() + except (UnicodeError, ValueError) as exc: + return failure(relative_file, "qmake", exc) + + +def _import_paths(facts, accepted, module, key): + """Ordinary .pro files may declare hints without creating a QML module.""" + for group, visibility in (("QML_IMPORT_PATH", "tooling"), ("QMLPATHS", "build")): + for entry in accepted.get(group, []): + facts.add_chars("qt_import_path", (key, group, entry.value), entry.value, + entry.start, entry.end, owner=module, module_key=key, + value=entry.value, visibility=visibility, qmake_variable=group, + source_value=entry.source_value, path_origin=entry.path_origin) + + +def extract_qmake(path: Path, *, root: Path | None = None) -> dict: + return extract_project(path, root, "qmake", parse_qmake) diff --git a/graphify/extractors/qml_qmake_values.py b/graphify/extractors/qml_qmake_values.py new file mode 100644 index 0000000000..60e1508a5b --- /dev/null +++ b/graphify/extractors/qml_qmake_values.py @@ -0,0 +1,180 @@ +"""Bounded qmake lexical assignments with original positions and no evaluation. + +This reader owns statement coverage and literal path interpretation. The caller +owns module facts; uncertain metadata cannot become module lookup authority. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from graphify.extractors.qml_project_read import literal_path + + +PATH_KEYS = {"SOURCES", "HEADERS", "RESOURCES", "DISTFILES", "QML_FILES", + "QML_IMPORT_PATH", "QMLPATHS"} +INTEREST = PATH_KEYS | {"TARGET", "QML_IMPORT_NAME", "QML_IMPORT_VERSION", + "QML_IMPORT_MAJOR_VERSION", "QML_IMPORT_MINOR_VERSION"} +_ASSIGNMENT = re.compile(r"([A-Za-z_][A-Za-z_0-9.]*)\s*(=|\+=|-=|\*=|~=)\s*(.*)") +_TOKENS = re.compile(r'"[^"\r\n]*"|\x27[^\x27\r\n]*\x27|[^\s]+') +_MAX_SCOPES = 64 + + +@dataclass(frozen=True) +class QmakeValue: + value: str + source_value: str + start: int + end: int + path_origin: str = "literal" + + +def statements(text): + """Join continuations with a position map into the unmodified source text.""" + pending, positions, pos = "", [], 0 + for raw in text.splitlines(keepends=True): + body = raw.rstrip("\r\n") + quote, length = "", len(body) + for index, char in enumerate(body): + if char in "\"'": + quote = "" if quote == char else char if not quote else quote + if char == "#" and not quote: + length = index + break + value = body[:length] + if pos == 0 and value.startswith("\ufeff"): + value = value[1:] + offset = 1 + else: + offset = 0 + continued = value.rstrip().endswith("\\") + if continued: + value = value.rstrip()[:-1] + pending += value + positions.extend(range(pos + offset, pos + offset + len(value))) + if continued: + pending += " " + positions.append(pos + len(body)) + elif pending.strip(): + yield pending, positions + pending, positions = "", [] + else: + pending, positions = "", [] + pos += len(raw) + if pending.strip(): + yield pending, positions + + +def values(text, offset, positions, key, source_file): + """Interpret only an exact PWD prefix; reject all other expansions and escapes.""" + result, previous_end = [], 0 + for token in _TOKENS.finditer(text): + spelling = token.group() + if ((result and token.start() == previous_end) or + spelling.count('"') % 2 or spelling.count("'") % 2 or + (not spelling.startswith(('"', "'")) and any(char in spelling for char in "\"'"))): + return None, "conditional_or_expanded_qmake_value" + previous_end = token.end() + value = spelling[1:-1] if spelling.startswith(('"', "'")) else spelling + source_value = value + origin = "literal" + if key in PATH_KEYS and (value == "$$PWD" or value.startswith("$$PWD/")): + value, origin = value[5:].lstrip("/") or ".", "current_file_pwd" + if any(char in value for char in "$(){};*"): + return None, "conditional_or_expanded_qmake_value" + if key in PATH_KEYS: + if max(len(value.encode("utf-8")), len(source_value.encode("utf-8"))) > 384: + return None, "qmake_path_limit" + if literal_path(source_file, value) is None: + return None, "qmake_path_outside_accepted_root" + start = positions[offset + token.start()] + end = positions[offset + token.end() - 1] + 1 + result.append(QmakeValue(value, source_value, start, end, origin)) + return result, None + + +def assignments(facts): + """Retain observable lexical coverage while withholding failed module authority.""" + accepted, scope = {}, 0 + for statement, positions in statements(facts.text): + text = statement.strip() + start, end = positions[0], positions[-1] + 1 + + # Balanced scope delimiters have no branch truth. Relevance belongs to + # statements inside them, so an unrelated toolchain scope is harmless. + if text == "}": + scope -= 1 + if scope < 0: + facts.reject("unbalanced_qmake_scope") + scope = 0 + continue + if text in {"} else {", "}else{"}: + if scope == 0: + facts.reject("unbalanced_qmake_scope") + continue + if text.endswith("{") and not _ASSIGNMENT.fullmatch(text): + scope += 1 + if scope > _MAX_SCOPES: + facts.reject("qmake_scope_limit") + return accepted + continue + + # A log string can mention qmake assignment syntax without changing + # project facts. Classify known display statements before searching keys. + if re.fullmatch(r"(?:message|warning)\([^\r\n]*\)", text): + _coverage(facts, "qt_qmake_statement", "ignored", "unrelated_build_statement", + start, end, conditional=bool(scope)) + continue + + match = _ASSIGNMENT.fullmatch(statement.strip()) + conditional = bool(scope) + if match: + leading = len(statement) - len(statement.lstrip()) + else: + # A condition prefix remains opaque. It may contain qmake test + # functions; finding an assignment is never evaluating the prefix. + candidates = list(_ASSIGNMENT.finditer(statement)) + match = candidates[-1] if candidates else None + leading, conditional = 0, True + if not match: + _coverage(facts, "qt_qmake_statement", "unresolved", "unsupported_qmake_statement", + start, end, conditional=bool(scope)) + facts.reject("unsupported_qmake_statement", facts.byte_offsets[start], facts.byte_offsets[end]) + continue + key, operator, value = match.groups() + if key == "PWD": + facts.reject("qmake_pwd_reassignment", facts.byte_offsets[start], facts.byte_offsets[end]) + continue + if key not in INTEREST: + _coverage(facts, "qt_qmake_statement", "ignored", "unrelated_build_setting", + start, end, conditional=conditional, qmake_variable=key) + continue + if conditional: + _coverage(facts, "qt_qmake_statement", "unresolved", "conditional_qmake_value", + start, end, conditional=True, qmake_variable=key) + facts.reject("conditional_or_expanded_qmake_value", facts.byte_offsets[start], facts.byte_offsets[end]) + continue + entries, reason = values(value, leading + match.start(3), positions, key, facts.file) + if entries is None or operator not in {"=", "+="}: + facts.reject(reason or "conditional_or_expanded_qmake_value", + facts.byte_offsets[start], facts.byte_offsets[end]) + continue + # Individual lexical values remain inspectable even if a later unknown + # mutation blocks the file's module authority. They never enter lookup. + for entry in entries: + facts.add_chars("qt_qmake_assignment", (key, operator, entry.value), key, + entry.start, entry.end, status="observed", conditional=False, + reason="unconditional_literal_assignment", qmake_variable=key, + operator=operator, value=entry.value, source_value=entry.source_value, + path_origin=entry.path_origin) + accepted[key] = accepted.get(key, []) + entries if operator == "+=" else entries + if scope: + facts.reject("unbalanced_qmake_scope") + return accepted + + +def _coverage(facts, kind, status, reason, start, end, **fields): + """Coverage records use safe categories, never unbounded source/provider text.""" + variable = fields.get("qmake_variable", "statement") + facts.add_chars(kind, (variable, status, reason), variable, start, end, + status=status, reason=reason, **fields) diff --git a/graphify/extractors/qml_resources.py b/graphify/extractors/qml_resources.py new file mode 100644 index 0000000000..558d452aa0 --- /dev/null +++ b/graphify/extractors/qml_resources.py @@ -0,0 +1,98 @@ +"""Bounded QRC source declarations without DTD/entity expansion or target reads.""" +from __future__ import annotations + +import posixpath +from pathlib import Path +from xml.parsers import expat + +from graphify.extractors.qml_project_read import ( + MetadataError, ProjectFacts, extract_project, failure, literal_path, +) + + +def resource_url(prefix: str, alias: str) -> str | None: + """Accept exact portable resource paths; traversal is never normalized away.""" + prefix, alias = prefix.replace("\\", "/"), alias.replace("\\", "/") + if not alias or alias.startswith("/") or any(char in prefix + alias for char in ":%?#\x00"): + return None + joined = prefix.rstrip("/") + "/" + alias + if any(part in {".", ".."} for part in joined.split("/")): + return None + return "qrc:/" + joined.lstrip("/") + + +def parse_qrc(source: bytes | str, *, relative_file="resources.qrc") -> dict: + try: + facts = ProjectFacts(source, relative_file, "qrc") + parser = expat.ParserCreate("UTF-8") + parser.SetParamEntityParsing(expat.XML_PARAM_ENTITY_PARSING_NEVER) + stack, record = [], None + prefix, locale = "/", "" + + def forbidden(*_args): + raise MetadataError("QML_QRC_ENTITY", "xml_dtd_or_entity_forbidden") + + def start(name, attributes): + nonlocal prefix, locale, record + if len(stack) > 16: + raise MetadataError("QML_PROJECT_LIMIT", "qrc_depth_limit") + if name == "RCC" and not stack: + if set(attributes) - {"version"}: + facts.reject("unsupported_qrc_attribute") + elif name == "qresource" and stack == ["RCC"]: + if set(attributes) - {"prefix", "lang"}: + facts.reject("unsupported_qresource_attribute") + prefix, locale = attributes.get("prefix", "/"), attributes.get("lang", "") + elif name == "file" and stack == ["RCC", "qresource"]: + if set(attributes) - {"alias", "compress", "compression-algorithm", "threshold", "empty"}: + facts.reject("unsupported_qrc_file_attribute") + record = {"start": parser.CurrentByteIndex, "alias": attributes.get("alias"), + "parts": [], "empty": attributes.get("empty", "false")} + else: + facts.reject("unsupported_qrc_structure") + stack.append(name) + + def data(value): + if record is not None and stack == ["RCC", "qresource", "file"]: + record["parts"].append(value) + elif value.strip(): + facts.reject("unexpected_qrc_text") + + def close(name): + nonlocal record + if name == "file" and record is not None: + target = "".join(record["parts"]).strip() + alias = record["alias"] if record["alias"] is not None else target + logical = resource_url(prefix, alias) + path = literal_path(relative_file, target) + end = facts.raw.find(b">", parser.CurrentByteIndex) + 1 + if logical is None or path is None: + facts.reject("resource_path_escape_or_invalid_alias", record["start"], end, + code="QML_QRC_PATH") + elif record["empty"] == "true": + facts.reject("empty_resource_has_no_source_content", record["start"], end) + else: + facts.add("resource_alias", (logical, target, locale), logical, + record["start"], end, logical_url=logical, + target_path=path, value=target, prefix=prefix, alias=alias, + locale=locale, origin="qrc", generated=False) + record = None + stack.pop() + + parser.StartDoctypeDeclHandler = forbidden + parser.EntityDeclHandler = forbidden + parser.ExternalEntityRefHandler = forbidden + parser.StartElementHandler, parser.EndElementHandler = start, close + parser.CharacterDataHandler = data + parser.Parse(facts.raw, True) + if not facts.raw.strip() or not facts.nodes: + facts.reject("missing_qrc_root") + return facts.result() + except (expat.ExpatError, UnicodeError, ValueError) as exc: + if isinstance(exc, expat.ExpatError): + exc = MetadataError("QML_QRC_SYNTAX", "invalid_resource_xml") + return failure(relative_file, "qrc", exc) + + +def extract_qrc(path: Path, *, root: Path | None = None) -> dict: + return extract_project(path, root, "qrc", parse_qrc) diff --git a/graphify/extractors/qml_script_syntax.py b/graphify/extractors/qml_script_syntax.py new file mode 100644 index 0000000000..3fbc8783c5 --- /dev/null +++ b/graphify/extractors/qml_script_syntax.py @@ -0,0 +1,85 @@ +"""Bounded JS parsing with byte-preserving Qt directives; no evaluation/import IO.""" +from __future__ import annotations + +import re +from pathlib import Path + +from graphify.extractors.qml_ast import QmlInputError + +_PRAGMA = re.compile(rb'\s*\.pragma\s+library\s*(?://[^\r\n]*)?$') +_IMPORT = re.compile(rb'\s*\.import\s+["\']([^"\'\r\n]+)["\']\s+as\s+([A-Za-z_$][\w$]*)\s*(?://[^\r\n]*)?$') + + +def _next_state(line: bytes, state: str) -> str: + """Directives inside comments/templates/continued strings are ordinary text.""" + position = 0 + while position < len(line): + char = line[position:position + 1] + pair = line[position:position + 2] + if state == "comment": + if pair == b"*/": + state, position = "", position + 2 + else: + position += 1 + elif state: + if char == b"\\": + position += 2 + elif char == state.encode(): + state, position = "", position + 1 + else: + position += 1 + elif pair == b"//": + break + elif pair == b"/*": + state, position = "comment", position + 2 + elif char in {b'"', b"'", b"`"}: + state, position = char.decode(), position + 1 + else: + position += 1 + return state + + +def parse_script(path: Path): + """Return original bytes, a same-offset tree and literal Qt directive facts.""" + try: + if path.stat().st_size > 5_000_000: + raise QmlInputError("QML_SCRIPT_LIMIT", "QML imported script exceeds 5 MB") + with path.open("rb") as stream: + source = stream.read(5_000_001) + source.decode("utf-8") + except (OSError, UnicodeError) as exc: + raise QmlInputError("QML_SCRIPT_READ", "Cannot read UTF-8 QML imported script") from exc + if len(source) > 5_000_000: + raise QmlInputError("QML_SCRIPT_LIMIT", "QML imported script exceeds 5 MB") + masked, directives, state, offset = [], [], "", 0 + for row, line in enumerate(source.splitlines(keepends=True)): + body = line.rstrip(b"\r\n") + pragma, imported = (_PRAGMA.fullmatch(body), _IMPORT.fullmatch(body)) if not state else (None, None) + if pragma or imported: + directives.append({"kind": "pragma" if pragma else "import", "start_byte": offset, + "end_byte": offset + len(body), "row": row, + "value": imported[1].decode() if imported else "library", + "qualifier": imported[2].decode() if imported else ""}) + masked.append(b" " * len(body) + line[len(body):]) + else: + masked.append(line) + state = _next_state(line, state) + offset += len(line) + if path.suffix.lower() == ".mjs" and directives: + raise QmlInputError("QML_SCRIPT_UNSUPPORTED", "Qt directives belong to classic .js scripts, not ECMAScript modules") + try: + from tree_sitter import Language, Parser + import tree_sitter_javascript + tree = Parser(Language(tree_sitter_javascript.language())).parse(b"".join(masked)) + except Exception as exc: + raise QmlInputError("QML_SCRIPT_PARSER", "Cannot load the JavaScript parser for a QML import") from exc + if tree.root_node.has_error: + raise QmlInputError("QML_SCRIPT_SYNTAX", "QML imported script syntax is incomplete or invalid") + pending, count = [(tree.root_node, 0)], 0 + while pending: + node, depth = pending.pop() + count += 1 + if count > 100_000 or depth > 256: + raise QmlInputError("QML_SCRIPT_LIMIT", "QML imported script exceeds AST size/depth bounds") + pending.extend((child, depth + 1) for child in node.named_children) + return source, tree.root_node, directives diff --git a/graphify/extractors/qml_scripts.py b/graphify/extractors/qml_scripts.py new file mode 100644 index 0000000000..d1ccf5bf58 --- /dev/null +++ b/graphify/extractors/qml_scripts.py @@ -0,0 +1,192 @@ +"""QML-owned overlays for already accepted JS files; ordinary JS facts stay intact.""" +from __future__ import annotations + +import hashlib +from pathlib import Path + +from graphify.extractors.qml_ast import failure +from graphify.extractors.qml_expressions import ExpressionCollector +from graphify.extractors.qml_facts import FactBuilder, field, qml_metadata, text +from graphify.extractors.qml_js_scopes import FUNCTIONS, local_declarations, reassigned_names +from graphify.extractors.qml_script_syntax import parse_script +from graphify.qml_resolution_types import relative_reference + + +def _literal(node, source): + value = text(node, source) + return value[1:-1] if value[:1] in {"'", '"'} and value[-1:] == value[:1] and "\\" not in value else "" + + +def _exports(program, source): + """Explicit module exports control importer visibility; locals stay file-owned.""" + exports = {} + for statement in program.named_children: + if statement.type != "export_statement": + continue + if statement.child_by_field_name("source"): + # Re-exports require an explicit module-export graph, not a local + # same-name fallback. This profile leaves them unavailable. + continue + declaration = statement.child_by_field_name("declaration") + if declaration: + if declaration.type in FUNCTIONS: + name = field(declaration, "name", source) + exports.setdefault(name, []).append("default" if text(statement, source).startswith("export default") else name) + else: + for declarator in declaration.named_children: + if declarator.type == "variable_declarator": + from graphify.extractors.qml_js_scopes import bound_names + for name in bound_names(declarator.child_by_field_name("name"), source): + exports.setdefault(name, []).append(name) + for clause in statement.named_children: + if clause.type == "export_clause": + for specifier in clause.named_children: + name = field(specifier, "name", source) + alias = field(specifier, "alias", source) or name + exports.setdefault(name, []).append(alias) + return exports + + +def _script_imports(facts, program, directives, owner): + for directive in directives: + if directive["kind"] != "import": + continue + node = facts.add("script_import", directive["qualifier"], program, owner, + semantic_key=str(directive["start_byte"]), script_file=facts.relative_file, + value=directive["value"], qualifier=directive["qualifier"], import_kind="namespace") + node["label"] = "QML script import: " + directive["qualifier"] + node["source_location"] = f'L{directive["row"] + 1}' + # The directive is masked only for parsing; preserve the original source span. + md = qml_metadata(node) + md["span"] = {"start_byte": directive["start_byte"], "end_byte": directive["end_byte"], + "start_row": directive["row"], "start_column": 0, + "end_row": directive["row"], "end_column": directive["end_byte"] - directive["start_byte"]} + from graphify.extractors.qml_facts import encode_metadata + node["metadata"]["qml"] = encode_metadata({k: v for k, v in md.items() if k != "raw_values"}) + for statement in program.named_children: + if statement.type != "import_statement": + continue + value = _literal(statement.child_by_field_name("source"), facts.source) + for clause in statement.named_children: + if clause.type != "import_clause": + continue + for binding in clause.named_children: + if binding.type == "namespace_import": + names = [(text(binding.named_children[-1], facts.source), "namespace", "")] + elif binding.type == "identifier": + names = [(text(binding, facts.source), "named", "default")] + elif binding.type == "named_imports": + names = [(field(s, "alias", facts.source) or field(s, "name", facts.source), + "named", field(s, "name", facts.source)) for s in binding.named_children] + else: + names = [] + for name, kind, exported in names: + node = facts.add("script_import", name, binding, owner, + semantic_key=f"{binding.start_byte}:{name}", script_file=facts.relative_file, + value=value, qualifier=name, import_kind=kind, exported_name=exported) + node["label"] = "QML script import: " + name + + +def _overlay(path: Path, root: Path): + source, program, directives = parse_script(path) + facts = FactBuilder(path, root, source) + library = any(item["kind"] == "pragma" for item in directives) + owner = facts.add("script_file", path.name, program, script_file=facts.relative_file, + pragma_library=library, script_profile="module" if path.suffix.lower() == ".mjs" else "classic") + owner["label"] = "QML script: " + path.name + _script_imports(facts, program, directives, owner) + exported, declarations, reassigned = _exports(program, source), [], reassigned_names(program, source) + for statement in program.named_children: + declaration = statement.child_by_field_name("declaration") if statement.type == "export_statement" else statement + if declaration is None: + continue + if declaration.type in FUNCTIONS: + declarations.append((field(declaration, "name", source), declaration)) + elif declaration.type in {"lexical_declaration", "variable_declaration"}: + for variable in declaration.named_children: + if variable.type != "variable_declarator": + continue + function = variable.child_by_field_name("value") + if function is not None and function.type in FUNCTIONS: + from graphify.extractors.qml_js_scopes import bound_names + declarations.extend((name, function) for name in bound_names(variable.child_by_field_name("name"), source)) + collector, environment = ExpressionCollector(facts), {} + for name, syntax in declarations: + key = hashlib.sha256(f'{name}:{syntax.start_byte}'.encode()).hexdigest() + node = facts.add("qml_script_function", name, syntax, owner, semantic_key=key, + script_file=facts.relative_file, static_callable=name not in reassigned, + export_names=exported.get(name, []) if path.suffix.lower() == ".mjs" else [name]) + node["label"] = "QML script: " + (name or "default") + environment[name] = node["id"] if name not in reassigned else "" + collector.functions[syntax.start_byte, owner["id"]] = node + # Top-level values also shadow names; no QML document's IDs leak into a script. + for name, _ in local_declarations(program, source): + environment.setdefault(name, "") + for (_, syntax), node in zip(declarations, [n for n in facts.nodes if qml_metadata(n).get("kind") == "qml_script_function"]): + collector.function_body(syntax, node, environment) + for statement in program.named_children: + if statement.type not in {"import_statement", "export_statement"} | FUNCTIONS: + collector.scan(statement, owner, environment) + return {"nodes": facts.nodes, "edges": facts.edges} + + +def collect_qml_scripts(paths, per_file, *, root: Path) -> None: + """Only enrich accepted imports, including accepted literal script dependencies. + + Never discover paths, load network resources or alter shared JS extraction. + Failures attach to the owning accepted result so publication safety can reject. + """ + if not any(qml_metadata(node).get("kind") in {"import", "export"} + for result in per_file if result for node in result.get("nodes", [])): + return + accepted = {} + anchor = Path(root).resolve() + for path, result in zip(paths, per_file): + if not result or Path(path).suffix.lower() not in {".js", ".mjs"}: + continue + try: + relative = Path(path).resolve().relative_to(anchor).as_posix() + except ValueError: + continue + accepted[relative] = Path(path), result + wanted = set() + for path, result in zip(paths, per_file): + if not result: + continue + try: + source = Path(path).resolve().relative_to(anchor).as_posix() + except ValueError: + continue + for node in result.get("nodes", []): + md = qml_metadata(node) + if md.get("kind") == "import" and md.get("import_kind") == "script": + target = relative_reference(source, md.get("value") or "") + if target in accepted: + wanted.add(target) + elif md.get("kind") == "export" and md.get("export_kind") == "script": + target = relative_reference(source, md.get("target_path") or "") + if target in accepted: + wanted.add(target) + visited = set() + while wanted - visited: + name = sorted(wanted - visited)[0] + visited.add(name) + path, result = accepted[name] + if len(visited) > 256: + result.setdefault("qml_failures", []).append({"code": "QML_SCRIPT_LIMIT", "source_file": name}) + return + try: + overlay = _overlay(path, Path(root)) + except Exception as exc: + failed = failure(path, Path(root), exc) + result.setdefault("qml_failures", []).extend(failed["qml_failures"]) + result.setdefault("diagnostics", []).extend(failed["diagnostics"]) + continue + result.setdefault("nodes", []).extend(overlay["nodes"]) + result.setdefault("edges", []).extend(overlay["edges"]) + for node in overlay["nodes"]: + md = qml_metadata(node) + if md.get("kind") == "script_import": + target = relative_reference(name, md.get("value") or "") + if target in accepted: + wanted.add(target) diff --git a/graphify/extractors/qml_source_identity.py b/graphify/extractors/qml_source_identity.py new file mode 100644 index 0000000000..255e98168f --- /dev/null +++ b/graphify/extractors/qml_source_identity.py @@ -0,0 +1,43 @@ +"""Relative Qt fact ownership after physical admission, without target authority. + +Readers retain source bytes, limits and publication ownership. This stateless +boundary reuses native spelling admission and walked-name selection; it reads +identity metadata only and stores no root, index or cache between calls. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from graphify.source_identity import normalize_input_source, walked_relative_source + + +class SourceInputIdentityError(ValueError): + """Preserve the native admission category without carrying backend bodies.""" + code = "SOURCE_INPUT_IDENTITY_FAILED" + reason = "native_source_spelling_unavailable" + + def __init__(self): + super().__init__("SOURCE_INPUT_IDENTITY_FAILED: native source spelling unavailable; " + "repair filesystem access and retry") + + +def relative_qml_source(path: Path, root: Path | None) -> str: + """Keep discovered lexical owners while rejecting foreign physical targets.""" + try: + admitted = normalize_input_source(Path(path), root) + except ValueError: + raise SourceInputIdentityError() from None + # POSIX link/.. traversal follows the physical parent. Capture that input + # before lexical abspath normalization can describe a different file. + physical = admitted.resolve() + written = physical if ".." in admitted.parts else Path(os.path.abspath(admitted)) + anchor = Path(root).resolve() if root is not None else physical.parent + + def realpath(value: str, _cwd: str) -> Path: + # Explicit inputs are absolute; the shared naming boundary owns neither + # this reader's filesystem calls nor a second memoization lifetime. + return Path(value).resolve() + + return walked_relative_source(written, anchor, physical, + realpath=realpath, cwd=os.getcwd()).as_posix() diff --git a/graphify/extractors/qml_types.py b/graphify/extractors/qml_types.py new file mode 100644 index 0000000000..a1f88e06c7 --- /dev/null +++ b/graphify/extractors/qml_types.py @@ -0,0 +1,179 @@ +"""Literal generated QML type descriptions, retained separately from source APIs.""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +from graphify.extractors.qml_ast import QmlInputError, load_parser +from graphify.extractors.qml_cmake import uri, version +from graphify.extractors.qml_facts import field, text +from graphify.extractors.qml_project_read import MetadataError, ProjectFacts, extract_project, failure + +_COMPONENT_FIELDS = {"name", "prototype", "file", "exports", "exportMetaObjectRevisions", + "isSingleton", "isCreatable", "isComposite", "defaultProperty", "attachedType", + "extension", "accessSemantics", "interfaces", "isStructured", "isValueType", + "isSequence", "sequenceValueType", "isRoot", "isCustomParser", "deferredNames", + "immediateNames", "isScript", "isExtendedTypeNamespace", "extensionIsJavaScript", + "hasCustomParser", "enforcesScopedEnums", "valueType", "aliases", + "extensionIsNamespace", "isJavaScriptBuiltin"} +_MEMBER_FIELDS = {"name", "type", "isReadonly", "isList", "isPointer", "revision", "isRequired", + "isFinal", "index", "read", "write", "notify", "bindable", "isConstructor", + "isJavaScriptFunction", "isCloned", "isConst", "isMethodConstant", "values", + "alias", "isFlag", "isScoped", "isEnum", "isUnderlyingType", "underlyingType", + "privateClass", "isConstant", "reset"} + + +def _literal(node, raw): + if node.type == "expression_statement" and len(node.named_children) == 1: + return _literal(node.named_children[0], raw) + if node.type == "array": + return [_literal(child, raw) for child in node.named_children if child.type != "comment"] + if node.type == "object": + values = {} + for pair in node.named_children: + if pair.type == "comment": + continue + key, value = pair.child_by_field_name("key"), pair.child_by_field_name("value") + if pair.type != "pair" or key is None or key.type != "string" or value is None: + raise MetadataError("QML_TYPES_UNSUPPORTED", "nonliteral_enum_entry") + name = _literal(key, raw) + if name in values: + raise MetadataError("QML_TYPES_UNSUPPORTED", "duplicate_enum_entry") + values[name] = _literal(value, raw) + return values + if node.type in {"string", "number", "true", "false", "null", "unary_expression"}: + try: + return json.loads(text(node, raw)) + except ValueError as exc: + raise MetadataError("QML_TYPES_UNSUPPORTED", "non_json_literal") from exc + raise MetadataError("QML_TYPES_UNSUPPORTED", "computed_type_description_value") + + +def _bindings(syntax, facts, allowed): + initializer = syntax.child_by_field_name("initializer") + values, nested = {}, [] + for child in initializer.named_children if initializer else []: + if child.type == "comment": + continue + if child.type == "ui_object_definition": + nested.append(child) + continue + if child.type != "ui_binding": + raise MetadataError("QML_TYPES_UNSUPPORTED", "unsupported_type_description_syntax") + name = field(child, "name", facts.raw) + if name not in allowed or name in values: + raise MetadataError("QML_TYPES_UNSUPPORTED", "unknown_or_duplicate_description_field") + value = child.child_by_field_name("value") + if value is None: + raise MetadataError("QML_TYPES_UNSUPPORTED", "missing_description_value") + values[name] = _literal(value, facts.raw) + literal = values[name] + if name.startswith(("is", "has", "enforces")) and not isinstance(literal, bool): + raise MetadataError("QML_TYPES_UNSUPPORTED", "invalid_boolean_description_field") + if name in {"name", "type", "prototype", "file", "notify", "read", "write", "bindable"} and not isinstance(literal, str): + raise MetadataError("QML_TYPES_UNSUPPORTED", "invalid_string_description_field") + return values, nested + + +def _field_records(facts, owner, values, syntax): + """Keep every accepted tooling field independently, avoiding list sanitation.""" + for name, value in values.items(): + entries = list(value.items()) if isinstance(value, dict) else list(enumerate(value)) if isinstance(value, list) else [(0, value)] + if len(entries) > 10_000: + raise MetadataError("QML_PROJECT_LIMIT", "type_field_entry_limit") + for ordinal, (entry_key, entry) in enumerate(entries): + if isinstance(entry, (list, dict)): + raise MetadataError("QML_TYPES_UNSUPPORTED", "nested_type_field_literal") + facts.add("qmltypes_field", (owner["id"], name, ordinal), name, + syntax.start_byte, syntax.end_byte, owner=owner, + field_name=name, value=entry, position=ordinal, + enum_key=entry_key if isinstance(value, dict) else "", generated=True) + + +def _component(syntax, facts): + values, nested = _bindings(syntax, facts, _COMPONENT_FIELDS) + name = values.get("name") + if not isinstance(name, str) or not name: + raise MetadataError("QML_TYPES_UNSUPPORTED", "missing_component_name") + component = facts.add("qmltypes_component", name, name, syntax.start_byte, syntax.end_byte, + raw_name=name, prototype=values.get("prototype", ""), + singleton=values.get("isSingleton", False), + creatable=values.get("isCreatable", True), generated=True, + evidence_role="tooling_description", declared_file=values.get("file", "")) + _field_records(facts, component, values, syntax) + exports = values.get("exports", []) + if not isinstance(exports, list) or len(exports) > 10_000: + raise MetadataError("QML_PROJECT_LIMIT", "type_export_limit_or_shape") + for exported in exports: + match = re.fullmatch(r"([^/\s]+)/([^\s]+)\s+(\d{1,6}\.\d{1,6})", str(exported)) + if not match or not uri(match[1]) or not match[2].isidentifier(): + raise MetadataError("QML_TYPES_UNSUPPORTED", "invalid_type_export") + ver = version(match[3]) + assert ver is not None + facts.add("qmltypes_export", (name, exported), match[2], syntax.start_byte, syntax.end_byte, + owner=component, cpp_name=name, uri=match[1], raw_name=match[2], + major=ver[0], minor=ver[1], generated=True, evidence_role="tooling_description") + for ordinal, child in enumerate(nested): + kind = field(child, "type_name", facts.raw) + if kind not in {"Property", "Method", "Signal", "Enum"}: + raise MetadataError("QML_TYPES_UNSUPPORTED", "unknown_component_description_object") + fields, parameters = _bindings(child, facts, _MEMBER_FIELDS) + member_name = fields.get("name") + if not isinstance(member_name, str) or not member_name: + raise MetadataError("QML_TYPES_UNSUPPORTED", "missing_member_name") + member = facts.add("qmltypes_member", (name, kind, member_name, ordinal), member_name, + child.start_byte, child.end_byte, owner=component, raw_name=member_name, + member_kind=kind.lower(), raw_type=fields.get("type", ""), cpp_name=name, + readonly=fields.get("isReadonly", False), is_list=fields.get("isList", False), + revision=fields.get("revision"), notify=fields.get("notify", ""), + generated=True, evidence_role="tooling_description") + _field_records(facts, member, fields, child) + for position, parameter in enumerate(parameters): + if field(parameter, "type_name", facts.raw) != "Parameter" or kind not in {"Method", "Signal"}: + raise MetadataError("QML_TYPES_UNSUPPORTED", "unsupported_member_child") + param, grandchildren = _bindings(parameter, facts, _MEMBER_FIELDS) + if grandchildren: + raise MetadataError("QML_TYPES_UNSUPPORTED", "nested_parameter_object") + parameter_node = facts.add("qmltypes_parameter", (name, kind, member_name, ordinal, position), + str(param.get("name", position)), parameter.start_byte, parameter.end_byte, + owner=member, raw_name=param.get("name", ""), raw_type=param.get("type", ""), + position=position, generated=True) + _field_records(facts, parameter_node, param, parameter) + + +def parse_qmltypes(source: bytes | str, *, relative_file="plugins.qmltypes") -> dict: + try: + facts = ProjectFacts(source, relative_file, "qmltypes") + program = load_parser().parse(facts.raw).root_node + if program.has_error: + raise MetadataError("QML_TYPES_SYNTAX", "invalid_type_description_syntax") + pending = [(program, 0)] + count = 0 + while pending: + node, depth = pending.pop() + count += 1 + if count > 100_000 or depth > 64: + raise MetadataError("QML_PROJECT_LIMIT", "type_description_ast_limit") + pending.extend((child, depth + 1) for child in node.named_children) + roots = [] + for child in program.named_children: + if child.type == "ui_object_definition": + roots.append(child) + elif child.type not in {"comment", "ui_import"}: + raise MetadataError("QML_TYPES_UNSUPPORTED", "unsupported_top_level_description") + if len(roots) != 1 or field(roots[0], "type_name", facts.raw) != "Module": + raise MetadataError("QML_TYPES_UNSUPPORTED", "missing_module_description") + module_values, components = _bindings(roots[0], facts, {"dependencies"}) + _field_records(facts, facts.file_node, module_values, roots[0]) + for syntax in components: + if field(syntax, "type_name", facts.raw) != "Component": + raise MetadataError("QML_TYPES_UNSUPPORTED", "unknown_module_description_object") + _component(syntax, facts) + return facts.result() + except (QmlInputError, UnicodeError, ValueError) as exc: + return failure(relative_file, "qmltypes", exc) + + +def extract_qmltypes(path: Path, *, root: Path | None = None) -> dict: + return extract_project(path, root, "qmltypes", parse_qmltypes) diff --git a/graphify/extractors/qt_cpp_access.py b/graphify/extractors/qt_cpp_access.py new file mode 100644 index 0000000000..b2ed1e7610 --- /dev/null +++ b/graphify/extractors/qt_cpp_access.py @@ -0,0 +1,192 @@ +"""Literal C++ to QML access occurrences, retaining engine and handle provenance.""" +from __future__ import annotations + +from pathlib import Path +import re + +from graphify.extractors.qt_cpp_access_patterns import assigned_handle, handle_argument, last_assignment, literal_map +from graphify.extractors.qt_cpp_calls import calls, conditional_at, literal_string +from graphify.extractors.qt_cpp_events import CPP_SUFFIXES, _failure +from graphify.extractors.qt_cpp_facts import QtFacts, owner_scope +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_exposure import is_qt_cpp_source +from graphify.extractors.qt_cpp_mapping import map_cpp +from graphify.extractors.qt_cpp_identity import CppDeclarationIdentity +from graphify.extractors.qt_cpp_loaders import (collect_loader_constructors, declared_sdk_type, literal_creation_arguments, + literal_loader_arguments, literal_url_expression, loader_operation_owner) +from graphify.extractors.qt_cpp_type_scope import NativeTypeScope +from graphify.extractors.qt_cpp_syntax import QtCppError, read_cpp, source_span +from graphify.extractors.qt_cpp_variables import simple_reference, type_name, variables_at +from graphify.extractors.qt_cpp_provider_expression import provider_expression + +_METHODS = {"load", "loadFromModule", "loadUrl", "setSource", "create", "createWithInitialProperties", "rootObjects", "rootObject", + "findChild", "property", "setProperty", "setParent", "invokeMethod", "read", "write", "setContextProperty", "setContextObject", "setInitialProperties"} + + +def _identity_fields(identities, name, position, role): + """Semantic joins use exact declarations; display spellings remain provenance.""" + value = identities.resolve(name, position) + fields = {role + "_declaration_id": value["declaration_id"], + role + "_identity_status": value["status"], role + "_identity_reason": value["reason"]} + if role == "provider": + fields["provider_scope_end"] = value["scope_span"].get("end_byte", -1) + return fields + + +def _site(facts, unit, mapping, call, identities, types, component_engines): + args, method = call["args"], call["name"] + variables = variables_at(unit, mapping, call["start_byte"], type_scope=types) + receiver = call["receiver"] + receiver_type = variables.get(receiver, "") + assigned = assigned_handle(unit, call) + common = {"owner_class": call["owner"].get("class_name") or "", "receiver_reference": receiver, + "owner_scope_key": owner_scope(unit, call["owner"]), + "receiver_type": receiver_type, "assigned_handle": assigned_handle(unit, call), + "assignment_byte": last_assignment(unit, call["owner"], assigned, call["start_byte"]), + "receiver_assignment_byte": last_assignment(unit, call["owner"], receiver, call["start_byte"]), + "conditional": conditional_at(unit, call["start_byte"]), "status": "pending", "reason": "", + "bridge_direction": "cpp_to_qml", + **_identity_fields(identities, receiver, call["start_byte"], "receiver"), + **_identity_fields(identities, assigned, call["start_byte"], "assigned")} + if method in {"load", "loadFromModule", "loadUrl", "setSource", "create", "createWithInitialProperties", "rootObjects", "rootObject", "setInitialProperties"}: + # A reassignment loses runtime identity, but its declared SDK type still + # identifies the observed API occurrence. Resolution keeps that uncertainty. + if not receiver_type: + spelling, declared_at = identities.type_binding(receiver, call["start_byte"]) + receiver_type = types.resolve(type_name(spelling), declared_at) if spelling else "" + # Incomplete source declarations are not external SDK authority either. + if (not loader_operation_owner(receiver_type, method) + or not declared_sdk_type(identities, types, receiver, call["start_byte"], receiver_type.removeprefix("::"))): + return + loading = method in {"load", "loadFromModule", "loadUrl", "setSource"} + supported = (literal_loader_arguments(receiver_type, method, args, types=types, position=call["start_byte"]) + if loading else literal_creation_arguments(method, args)) + values = {**common, "receiver_type": receiver_type, "operation": method, + "loader_supported": supported, "loader_reason": "" if supported else "loader_overload_unestablished", + "literal_url": literal_url_expression(args[0]["text"], types=types, identities=identities, position=call["start_byte"]) if args and method in {"load", "loadUrl", "setSource"} else None, + "module_uri": literal_string(args[0]["text"]) if args and method == "loadFromModule" else None, + "type_name": literal_string(args[1]["text"]) if len(args) >= 2 and method == "loadFromModule" else None} + if receiver_type == "QQmlComponent": + association = component_engines.get(common["receiver_declaration_id"]) + if association is not None: + values.update(association) + if association["component_engine_supported"] is False: + values.update(loader_supported=False, loader_reason="component_engine_unestablished") + if method == "rootObjects": + after = unit.code[call["end_byte"]:call["end_byte"] + 40] + values["single_root_selection"] = bool(re.match(rb"\s*\.\s*(?:first\(\)|at\(0\))", after)) + if method in {"createWithInitialProperties", "setInitialProperties"}: + values["properties"], values["literal_properties"] = literal_map(args[0]["text"] if args else "") + if not values["literal_properties"]: + values.update(status="dynamic", reason="computed_initial_properties") + for item in values["properties"]: + item["provider_type"] = variables.get(item["provider_reference"], "") + item.update(_identity_fields(identities, item["provider_reference"], call["start_byte"], "provider")) + facts.add("qml_load" if loading else "qml_root" if method in {"rootObjects", "rootObject", "create", "createWithInitialProperties"} else "initial_properties", method, call["span"], call["owner"].get("node_id"), **values) + elif method in {"setContextProperty", "setContextObject"}: + # Only direct engine.rootContext() chains or a typed QQmlContext receiver + # establish context provenance. Arbitrary factory/context aliases do not. + before = unit.code[max(0, call["start_byte"] - 120):call["start_byte"]].decode() + chain = re.search(r"([A-Za-z_]\w*)\s*(?:\.|->)rootContext\(\)\s*->\s*$", before) + engine = chain[1] if chain else "" + if not engine and receiver_type != "QQmlContext": + return + expression = args[1 if method == "setContextProperty" else 0]["text"] if len(args) >= (2 if method == "setContextProperty" else 1) else "" + declared = provider_expression(expression, identities, types, call["start_byte"]) + provider = declared.pop("provider_reference", "") + facts.add("context_exposure", method, call["span"], call["owner"].get("node_id"), **common, + engine_reference=engine, engine_type=variables.get(engine, ""), + **_identity_fields(identities, engine, call["start_byte"], "engine"), + **_identity_fields(identities, provider, call["start_byte"], "provider"), + exposed_name=literal_string(args[0]["text"]) if args and method == "setContextProperty" else None, + provider_reference=provider, provider_type=variables.get(provider, ""), operation=method, **declared) + elif method == "setParent": + # An observed parent mutation cannot establish a static QObject tree. + # Keep its source occurrence for conservative child-lookup rejection. + facts.add("qml_parent_mutation", method, call["span"], call["owner"].get("node_id"), + **common, operation=method) + elif method in {"findChild", "property", "setProperty", "invokeMethod", "read", "write"}: + reflection = {} + qualifier = call["callee"].rsplit("::", 1)[0] if "::" in call["callee"] else "" + sdk = "QMetaObject" if method == "invokeMethod" else "QQmlProperty" + if method == "invokeMethod" or qualifier.rsplit("::", 1)[-1] == "QQmlProperty": + if qualifier.rsplit("::", 1)[-1] != sdk: + return + # The shared call scanner starts at an identifier. Restore an + # explicit global qualifier before lexical lookup and source proof. + if unit.code[max(0, call["start_byte"] - 2):call["start_byte"]] == b"::": + qualifier = "::" + qualifier + call = {**call, "start_byte": call["start_byte"] - 2, + "span": source_span(unit.source, call["start_byte"] - 2, call["end_byte"])} + reflection = {"reflection_type": qualifier, + "reflection_supported": types.sdk_type(qualifier, call["start_byte"], sdk)} + receiver, role = handle_argument(args[0]["text"] if args else "") + name = literal_string(args[1]["text"]) if len(args) > 1 else None + else: + role, name = "variable", literal_string(args[0]["text"]) if args else None + # Only the default and exact documented enum options prove lookup depth. + # Computed flags and unsupported overloads must not silently recurse. + option = args[1]["text"].strip() if method == "findChild" and len(args) == 2 else "" if len(args) == 1 else "unsupported" + depth = {"": "recursive", "Qt::FindChildrenRecursively": "recursive", "Qt::FindDirectChildrenOnly": "direct"}.get(option, "unsupported") + callee = unit.code[call["start_byte"]:call["end_byte"]].split(b"(", 1)[0] + filter_type = re.search(rb"findChild\s*<\s*((?:::)?QObject)\s*\*\s*>\s*$", callee) + # The bounded filter is the SDK QObject pointer, not a same-spelled + # lexical alias or a source-defined class with different ancestry. + child_type_supported = bool(filter_type and + types.resolve(filter_type[1].decode(), call["start_byte"]) == "QObject" + and not types.classes.get("QObject")) + facts.add("qml_access", method, call["span"], call["owner"].get("node_id"), **{**common, "receiver_reference": receiver, + **_identity_fields(identities, receiver, call["start_byte"], "receiver"), + "receiver_assignment_byte": last_assignment(unit, call["owner"], receiver, call["start_byte"])}, + operation=method, lookup_name=name, handle_role=role, **reflection, + **({"find_child_options": depth, "find_child_type_supported": child_type_supported} if method == "findChild" else {})) + + +def _component_constructors(unit, mapping, facts, identities, types): + # Property wrappers remain separate from component load construction. + for match in re.finditer(rb"(? 1 else None, + conditional=conditional_at(unit, match.start()), status="pending", reason="", bridge_direction="cpp_to_qml") + + +def collect_qt_cpp_access(paths, per_file, *, root: Path, accepted_nodes=None, accepted_edges=None): + nodes = accepted_nodes if accepted_nodes is not None else [node for result in per_file if result for node in result.get("nodes", [])] + edges = accepted_edges if accepted_edges is not None else [edge for result in per_file if result for edge in result.get("edges", [])] + for path, result in zip(paths, per_file): + if not result or Path(path).suffix.lower() not in CPP_SUFFIXES or result.get("error"): + continue + try: + if not is_qt_cpp_source(path): + continue + unit = read_cpp(Path(path), Path(root)) + mapping, facts = map_cpp(unit, nodes, edges, root=Path(root)), QtFacts(unit) + mapping.bind_classes([{"node_id": md.get("class_id", ""), "qualified_name": md.get("class_name", "")} + for node in nodes if (md := qt_metadata(node)).get("kind") == "class"]) + identities = CppDeclarationIdentity(unit, mapping) + types = NativeTypeScope(unit, mapping) + component_engines = collect_loader_constructors(unit, mapping, facts, identities, types) + _component_constructors(unit, mapping, facts, identities, types) + for call in calls(unit, mapping, _METHODS): + _site(facts, unit, mapping, call, identities, types, component_engines) + result.setdefault("nodes", []).extend(facts.nodes) + result.setdefault("edges", []).extend(facts.edges) + except (QtCppError, ValueError) as error: + _failure(result, Path(path), Path(root), error) diff --git a/graphify/extractors/qt_cpp_access_patterns.py b/graphify/extractors/qt_cpp_access_patterns.py new file mode 100644 index 0000000000..019eebe0ee --- /dev/null +++ b/graphify/extractors/qt_cpp_access_patterns.py @@ -0,0 +1,53 @@ +"""Literal handle assignment and initial-property maps; unsupported dataflow is opaque.""" +from __future__ import annotations + +import re + +from graphify.extractors.qt_cpp_calls import literal_string +from graphify.extractors.qt_cpp_syntax import lexical_code, split_arguments + + +def assigned_handle(unit, call): + before = unit.code[max(call["owner"]["body"].start_byte, unit.code.rfind(b";", 0, call["start_byte"]) + 1):call["start_byte"]].decode() + match = re.search(r"\b([A-Za-z_]\w*)\s*=\s*$", before) + return match[1] if match else "" + + +def handle_argument(expression): + value = expression.strip() + root = re.fullmatch(r"([A-Za-z_]\w*)\s*(?:\.|->)\s*(?:rootObjects\(\)\.(?:first|at)\(0?\)|rootObject\(\))", value) + return (root[1], "root") if root else (value, "variable") if re.fullmatch(r"[A-Za-z_]\w*", value) else ("", "dynamic") + + +def literal_map(expression): + """Recognize direct QVariantMap initializer pairs only; no computed merging.""" + value = re.sub(r"^\s*(?:QVariantMap|QMap\s*<[^>]+>)\s*", "", expression.strip()) + if not value.startswith("{") or not value.endswith("}"): + return [], False + pairs = [] + for entry in split_arguments(value[1:-1].encode()): + if not entry.startswith("{") or not entry.endswith("}"): + return [], False + args = split_arguments(entry[1:-1].encode()) + key = literal_string(args[0]) if len(args) == 2 else None + if key is None: + return [], False + reference = args[1].strip().lstrip("&") + pairs.append({"ordinal": len(pairs), "name": key, "provider_reference": reference if re.fullmatch(r"[A-Za-z_]\w*", reference) else "", + "literal_value": literal_string(args[1]), "dynamic_value": not bool(re.fullmatch(r"[A-Za-z_]\w*", reference)) and literal_string(args[1]) is None}) + return pairs, bool(pairs) + + +def handle_reassigned(unit, owner, name, start, end): + if not name or end <= start: + return False + code = lexical_code(unit.source[start:end]).decode() + return bool(re.search(r"\b" + re.escape(name) + r"\s*=(?!=)", code)) + + +def last_assignment(unit, owner, name, position): + if not name or owner.get("body") is None: + return -1 + start = owner["body"].start_byte + matches = list(re.finditer(rb"\b" + re.escape(name.encode()) + rb"\s*=(?!=)", unit.code[start:position])) + return start + matches[-1].start() if matches else -1 diff --git a/graphify/extractors/qt_cpp_api_shape.py b/graphify/extractors/qt_cpp_api_shape.py new file mode 100644 index 0000000000..3a9cdeb129 --- /dev/null +++ b/graphify/extractors/qt_cpp_api_shape.py @@ -0,0 +1,62 @@ +"""Original declaration shapes for the bounded statically declared QObject API. + +One pointer or lvalue reference is an API hop. Root objects additionally admit +values for dot access/address-of. This does not prove runtime conversion, alias +identity, const-correct invocation or allocation; CV stays in lexical transport. +""" +from __future__ import annotations + +import re + +_NAME = r"(?:::)?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*" +_TYPE = re.compile(r"\s*(?:(?:const|volatile|struct|class)\s+)*(?P" + _NAME + + r")(?:(?:\s+(?:const|volatile)\b)*)\s*(?P\*(?:\s*(?:const|volatile)\b)*(?:\s*)|&)?\s*") + + +def api_shape(spelling, *, allow_value=False): + """Reject compound declarators before resolving a convenient named base class.""" + if not isinstance(spelling, str) or len(spelling.encode()) > 384: + return None + match = _TYPE.fullmatch(spelling) + if not match: + return None + suffix = match["shape"] or "" + kind = "pointer" if suffix.startswith("*") else "reference" if suffix == "&" else "value" + return (match["name"], kind) if allow_value or kind != "value" else None + + +def _type_start(declaration): + type_node = declaration.child_by_field_name("type") + if type_node is None: + return None + return min([type_node.start_byte] + [node.start_byte for node in declaration.named_children + if node.type == "type_qualifier" and node.start_byte < type_node.end_byte]) + + +def return_spelling(unit, declaration, function): + """The prefix includes original CV and every return pointer/reference token.""" + start = _type_start(declaration) + return unit.source[start:function.start_byte].decode().strip() if start is not None else "" + + +def declared_spelling(unit, declaration, declarator, identifier): + """Remove only the selected identifier; arrays and nested pointers stay visible.""" + start = _type_start(declaration) + if start is None or identifier is None: + return "" + if declarator.type == "init_declarator": + declarator = declarator.child_by_field_name("declarator") + if declarator is None: + return "" + prefix = unit.source[start:declarator.start_byte] + shape = unit.source[declarator.start_byte:identifier.start_byte] + unit.source[identifier.end_byte:declarator.end_byte] + return (prefix + shape).decode().strip() + + +def declarator_identifier(node): + """Find a declaration leaf without erasing its enclosing shape from transport.""" + while node is not None and node.type not in {"identifier", "field_identifier"}: + node = node.child_by_field_name("declarator") or next((child for child in node.named_children + if child.type in {"identifier", "field_identifier", "pointer_declarator", "reference_declarator", + "array_declarator", "parenthesized_declarator"}), None) + return node diff --git a/graphify/extractors/qt_cpp_api_types.py b/graphify/extractors/qt_cpp_api_types.py new file mode 100644 index 0000000000..f6aa6428a8 --- /dev/null +++ b/graphify/extractors/qt_cpp_api_types.py @@ -0,0 +1,60 @@ +"""Declaration-position API type facts for factory results and QObject properties. + +These facts establish declared C++ APIs, never allocation or runtime alias identity. +Only admitted canonical complete classes can own an API endpoint. +""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_mapping import _name, _roles +from graphify.extractors.qt_cpp_api_shape import api_shape, declared_spelling, declarator_identifier, return_spelling +from graphify.extractors.qt_cpp_registration import conditional_offset +from graphify.extractors.qt_cpp_syntax import walk, source_span + + +def api_type_fields(types, raw_type, position, *, conditional=False): + """Resolve original declaration spelling before any later lexical alias.""" + shape = api_shape(raw_type) + ids, reason = types.resolve_class(shape[0], position) if shape else ([], "api_type_shape_unsupported") + resolved = len(ids) == 1 and not reason and not conditional + return {"api_type_target_id": ids[0] if resolved else "", + "api_type_status": "resolved" if resolved else "dynamic" if conditional else "unsupported" if not shape else "unavailable", + "api_type_reason": "conditional_api_type" if conditional else reason, + "api_type_spelling": raw_type, "api_type_position": position} + + +def return_fields(unit, record, types): + """Pointer/reference return syntax belongs to ancestors of the callable AST.""" + declaration = record["syntax"] + declarators = [node for node in walk(declaration) if node.type == "function_declarator"] + own = [node for node in declarators if _name(unit, node.child_by_field_name("declarator")) + .split("::")[-1] == record["name"]] + node = own[0].parent if len(own) == 1 else None + while node is not None and node != declaration: + if node.type not in {"pointer_declarator", "reference_declarator"}: + return {"api_type_status": "unsupported", "api_type_reason": "return_declarator_unsupported"} + node = node.parent + raw = return_spelling(unit, declaration, own[0]) if len(own) == 1 else "" + return api_type_fields(types, raw, declaration.start_byte, + conditional=conditional_offset(unit, declaration.start_byte)) + + +def add_api_fields(mapping, facts, class_record, types): + """Public pointer fields retain their source role independently of Qt macros.""" + unit = mapping.unit + body = class_record["syntax"].child_by_field_name("body") + for declaration in body.named_children if body else (): + if declaration.type != "field_declaration" or any( + node.type == "function_declarator" for node in walk(declaration)): + continue + _, access = _roles(unit, declaration, class_record["syntax"]) + for declarator in declaration.children_by_field_name("declarator"): + name = _name(unit, declarator) + if not name: + continue + raw = declared_spelling(unit, declaration, declarator, declarator_identifier(declarator)) + fields = api_type_fields(types, raw, declaration.start_byte, + conditional=conditional_offset(unit, declaration.start_byte)) + facts.add("api_field", name, source_span(unit.source, declarator.start_byte, declarator.end_byte), + owner=class_record["node_id"] or None, class_id=class_record["node_id"], + class_name=class_record["qualified_name"], access=access, + status=class_record["status"], raw_type=raw, **fields) diff --git a/graphify/extractors/qt_cpp_calls.py b/graphify/extractors/qt_cpp_calls.py new file mode 100644 index 0000000000..bfe446cf64 --- /dev/null +++ b/graphify/extractors/qt_cpp_calls.py @@ -0,0 +1,139 @@ +"""Bounded literal/call syntax over comment/string-masked accepted C++ bytes.""" +from __future__ import annotations + +import json +import re + +from graphify.extractors.qt_cpp_syntax import source_span, walk + +_CALLEE = re.compile(rb'\b(?:[A-Za-z_]\w*|this)(?:(?:::|->|\.)[A-Za-z_]\w*)*(?:\s*<[^;{}\n]*>)?\s*\(') +_EMIT = re.compile(rb"\b(?:emit|Q_EMIT)\b(?=\s)") +_EMIT_OVERRIDE = re.compile(rb"(?m)^[ \t]*#[ \t]*(?:define|undef)[ \t]+(emit|Q_EMIT)\b") + + +def closing(code, position, opening=b"(", close=b")"): + depth = 0 + for index in range(position, min(len(code), position + 100_000)): + token = code[index:index + 1] + if token == opening: + depth += 1 + elif token == close: + depth -= 1 + if not depth: + return index + return None + + +def argument_ranges(code, start, end): + """Separators in nested selectors, lambdas and initializer maps are not arguments.""" + depths, ranges, begin = [0, 0, 0, 0], [], start + pairs = {40: (0, 1), 41: (0, -1), 91: (1, 1), 93: (1, -1), + 123: (2, 1), 125: (2, -1), 60: (3, 1), 62: (3, -1)} + for index in range(start, end): + token = code[index] + if token in pairs: + slot, change = pairs[token] + depths[slot] = max(0, depths[slot] + change) + if token == 44 and not any(depths): + ranges.append((begin, index)) + begin = index + 1 + if begin < end: + ranges.append((begin, end)) + return ranges + + +def _explicit_calls(unit): + """Prove standalone annotated calls in the accepted offset-preserving AST. + + Masked source rejects strings/comments; AST ownership rejects directives, + unevaluated expressions and macro arguments. A local annotation override + blocks SDK spelling authority without executing a preprocessor. + """ + positions = set() + overrides = {} + for override in _EMIT_OVERRIDE.finditer(unit.code): + overrides.setdefault(override[1], override.start()) + for marker in _EMIT.finditer(unit.code): + if overrides.get(marker[0], len(unit.code)) < marker.start(): + continue + before = marker.start() - 1 + while before >= 0 and unit.code[before] in b" \t\r\n": + before -= 1 + if unit.code[max(0, before - 1):before + 1].endswith((b".", b"->", b"::")): + continue + after = marker.end() + while after < len(unit.code) and unit.code[after] in b" \t\r\n": + after += 1 + positions.add(after) + if not positions: + return set() + return {(node.start_byte, node.end_byte) for node in walk(unit.root) + if node.type == "call_expression" and node.start_byte in positions + and node.parent and node.parent.type == "expression_statement" and not node.has_error} + + +def calls(unit, mapping, names, *, include_explicit=False): + """Only executable function-body occurrences, never declaration signatures.""" + explicit = _explicit_calls(unit) if include_explicit else set() + explicit_ends = {end: start for start, end in explicit} + for match in _CALLEE.finditer(unit.code): + left = match.group().rfind(b"(") + match.start() + callee = unit.code[match.start():left].decode().strip() + plain = re.sub(r"<.*>", "", callee).strip() + name = re.split(r"::|->|\.", plain)[-1] + owner = mapping.owner_at(match.start()) + body = owner.get("body") if owner else None + if body is None or not (body.start_byte <= match.start() < body.end_byte): + continue + right = closing(unit.code, left) + if right is None: + continue + before = unit.code[max(0, match.start() - 8):match.start()].rstrip() + computed_receiver = not ("->" in plain or "." in plain) and before.endswith((b"->", b".")) + # The full accepted outer call owns annotation and source extent. A + # nested factory sharing its start cannot become an unknown bare signal. + explicit_start = explicit_ends.get(right + 1) + annotated = explicit_start == match.start() or ( + explicit_start is not None and explicit_start < match.start() and computed_receiver) + if name not in names and not annotated: + continue + start = explicit_start if annotated and explicit_start is not None else match.start() + args = [{"text": unit.source[a:b].decode().strip(), "code": unit.code[a:b].decode().strip(), + "start_byte": a, "end_byte": b, "span": source_span(unit.source, a, b)} + for a, b in argument_ranges(unit.code, left + 1, right) if unit.source[a:b].strip()] + receiver = re.split(r"->|\.", plain)[-2] if "->" in plain or "." in plain else "" + record = {"name": name, "callee": plain, "receiver": receiver, "args": args, "owner": owner, + "computed_receiver": computed_receiver, + "start_byte": start, "end_byte": right + 1, + "span": source_span(unit.source, start, right + 1)} + if include_explicit: + record["explicit_emit"] = annotated + yield record + + +def literal_string(expression): + """Decode only a literal or supported literal wrapper; never execute C++/QML.""" + value = expression.strip() + for _ in range(4): + wrapper = re.fullmatch(r'(?:QStringLiteral|QLatin1String|QString|QByteArray|QUrl|QUrl::fromLocalFile)\s*\((.*)\)', value, re.S) + if wrapper: + value = wrapper[1].strip() + else: + break + if not re.fullmatch(r'(?:u8|u|U|L)?"(?:[^"\\]|\\.)*"', value, re.S): + return None + try: + decoded = json.loads(value[value.index('"'):]) + except ValueError: + return None + return decoded if isinstance(decoded, str) and len(decoded.encode()) <= 256 else None + + +def conditional_at(unit, position): + """Source conditions are evidence, not a claim that a branch executes.""" + from graphify.extractors.qt_cpp_syntax import walk + for syntax in walk(unit.root): + if syntax.type in {"if_statement", "switch_statement", "conditional_expression", "preproc_if", "preproc_ifdef"}: + if syntax.start_byte <= position < syntax.end_byte: + return True + return False diff --git a/graphify/extractors/qt_cpp_compat.py b/graphify/extractors/qt_cpp_compat.py new file mode 100644 index 0000000000..83cdb7c45b --- /dev/null +++ b/graphify/extractors/qt_cpp_compat.py @@ -0,0 +1,142 @@ +"""Narrow parse-view recovery for empty parameter defaults and Qt Q_UNUSED.""" +from __future__ import annotations + +import re + +_UNUSED = re.compile(rb"\bQ_UNUSED\s*\(") +_EMPTY = re.compile(rb"=\s*\{\s*\}") +_LOCAL_UNUSED = re.compile(rb"(?m)^\s*#\s*(?:define|undef)\s+Q_UNUSED\b") + + +def needs_recovery(code: bytes) -> bool: + """Cheap admission avoids extra parse work for unrelated C++ syntax.""" + return bool(_UNUSED.search(code) or _EMPTY.search(code)) + + +def _statement_code(code): + # Directive continuations are not function-body statements. Keep every + # byte/newline while excluding their tokens from candidate discovery. + result, continued, offset = bytearray(code), False, 0 + for line in code.splitlines(keepends=True): + directive = continued or line.lstrip().startswith(b"#") + if directive: + result[offset:offset + len(line)] = re.sub(rb"[^\r\n]", b" ", line) + continued = directive and line.rstrip(b"\r\n").endswith(b"\\") + offset += len(line) + return bytes(result) + + +def _declares_unused(tree, source, walk): + for node in walk(tree): + if node.type != "function_declarator": + continue + name = node.child_by_field_name("declarator") + if name and source[name.start_byte:name.end_byte] == b"Q_UNUSED": + return True + return False + + +def _arguments_end(code, opening): + """Require balanced single-argument syntax; the AST validates expression content.""" + depth = 1 + for index in range(opening + 1, len(code)): + token = code[index] + if token == 40: + depth += 1 + elif token == 41: + depth -= 1 + if not depth: + return index + elif token == 44 and depth == 1: + return None + return None + + +def _in_callable(node): + # The parser walk bounds depth before this ancestry check. Lambdas own + # executable bodies too; global initializers and signatures do not. + current = node.parent + while current: + if current.type in {"function_definition", "lambda_expression"}: + return True + current = current.parent + return False + + +def _unused_statements(source, code, tree, *, parse, walk): + if _LOCAL_UNUSED.search(code) or _declares_unused(tree, source, walk): + return source + statement_code = _statement_code(code) + parsed, candidates = bytearray(source), {} + for match in _UNUSED.finditer(statement_code): + start, opening = match.start(), match.end() - 1 + before = start - 1 + while before >= 0 and statement_code[before] in b" \t\r\n": + before -= 1 + if statement_code[max(0, before - 1):before + 1].endswith((b".", b"->", b"::")): + continue + closing = _arguments_end(statement_code, opening) + if closing is None: + continue + # Qt 6 defines Q_UNUSED(x) as (void)x;. Reuse the macro-name space and + # its closing parenthesis for the cast/semicolon. Argument bytes remain + # untouched, preserving evaluated nested calls and their original spans. + parsed[start:start + 8] = b"(void) " + parsed[opening], parsed[closing] = 32, 59 + candidates[start] = closing + 1 + if not candidates: + return source + accepted = set() + for node in walk(parse(bytes(parsed))): + statement = node.parent + if (node.type == "cast_expression" and not node.has_error and statement + and statement.type == "expression_statement" and not statement.has_error + and candidates.get(node.start_byte) == statement.end_byte + and statement.start_byte == node.start_byte and _in_callable(statement)): + accepted.add(node.start_byte) + # A proposed view is authoritative only for standalone executable macro + # statements. Restore declarations, return/value contexts and damaged uses. + for start, end in candidates.items(): + if start not in accepted: + parsed[start:end] = source[start:end] + return bytes(parsed) + + +def _empty_parameter_defaults(source, code, tree, walk): + parsed = bytearray(source) + for node in walk(tree): + if node.type != "optional_parameter_declaration": + continue + default = node.child_by_field_name("default_value") + if default is None or default.type != "compound_literal_expression": + continue + missing = default.child_by_field_name("type") + value = default.child_by_field_name("value") + equal = next((child for child in node.children if child.type == "="), None) + if (missing is None or not missing.is_missing or missing.type != "type_identifier" + or value is None or value.type != "initializer_list" or equal is None + or any(child.type != "comment" for child in value.named_children) + or not re.fullmatch(rb"\{\s*\}", code[value.start_byte:value.end_byte])): + continue + # The bundled grammar invents a missing compound-literal type for a + # valid empty default. Omitting only that default in the parse view + # preserves its declaration/signature and creates no expression fact. + parsed[equal.start_byte:default.end_byte] = re.sub( + rb"[^\r\n]", b" ", source[equal.start_byte:default.end_byte] + ) + return bytes(parsed) + + +def recover_cpp_syntax(source: bytes, code: bytes, *, parse, walk) -> bytes: + """Return an equal-length view using the caller's bounded parser/traversal. + + Source is already annotation-normalized; code retains the original lexical + ownership view. No files are read, discovered or executed by this helper. + """ + if not needs_recovery(code): + return source + tree = parse(source) + recovered = _unused_statements(source, code, tree, parse=parse, walk=walk) + if recovered != source: + tree = parse(recovered) + return _empty_parameter_defaults(recovered, code, tree, walk) diff --git a/graphify/extractors/qt_cpp_event_patterns.py b/graphify/extractors/qt_cpp_event_patterns.py new file mode 100644 index 0000000000..1ca184e54e --- /dev/null +++ b/graphify/extractors/qt_cpp_event_patterns.py @@ -0,0 +1,57 @@ +"""Literal Qt connection endpoint syntax, never compiler/runtime emulation.""" +from __future__ import annotations + +import re + +from graphify.extractors.qt_cpp_mapping import normalize_type +from graphify.extractors.qt_cpp_syntax import split_arguments + + +def endpoint(expression): + value = expression.strip() + legacy = re.fullmatch(r"(SIGNAL|SLOT)\s*\(\s*([A-Za-z_]\w*)\s*\((.*)\)\s*\)", value, re.S) + if legacy: + return {"form": "legacy", "class_name": "", "member_name": legacy[2], + "parameter_types": [normalize_type(v) for v in split_arguments(legacy[3].encode())], + "role": "signal" if legacy[1] == "SIGNAL" else "slot", "selector": True} + pointer = re.search(r"&\s*((?:[A-Za-z_]\w*::)+)([A-Za-z_]\w*)", value) + if not pointer: + return {"form": "dynamic", "class_name": "", "member_name": "", "parameter_types": [], "selector": False} + classes, name = pointer[1].rstrip(":"), pointer[2] + direct = re.fullmatch(r"\s*&\s*(?:[A-Za-z_]\w*::)+[A-Za-z_]\w*\s*", value) + selected = re.fullmatch(r"(?:qOverload|qConstOverload|qNonConstOverload)\s*<(.*?)>\s*\(\s*&[^()]+\s*\)", value, re.S) + alternate = re.fullmatch(r"(?:QOverload|QConstOverload|QNonConstOverload)\s*<(.*?)>::of\s*\(\s*&[^()]+\s*\)", value, re.S) + cast = re.fullmatch(r"static_cast\s*<\s*[^()]+\(\s*[^()]+::\s*\*\s*\)\s*\((.*?)\)\s*(?:const)?\s*>\s*\(\s*&[^()]+\s*\)", value, re.S) + selector = selected or alternate or cast + if not direct and selector is None: + return {"form": "dynamic", "class_name": classes, "member_name": name, "parameter_types": [], "selector": False} + return {"form": "member_pointer", "class_name": classes, "member_name": name, + "parameter_types": [normalize_type(v) for v in split_arguments(selector[1].encode())] if selector else [], + "selector": selector is not None, "role": ""} + + +def connection_flags(value): + if not value: + return {"declared_type": "AutoConnection", "flags": [], "explicit_type": False, "dynamic_flags": False} + tokens = [token.strip() for token in value.split("|")] + supported = {"AutoConnection", "DirectConnection", "QueuedConnection", "BlockingQueuedConnection", + "UniqueConnection", "SingleShotConnection"} + if not all(re.fullmatch(r"Qt::\w+", token) and token[4:] in supported for token in tokens): + return {"declared_type": "", "flags": [], "explicit_type": True, "dynamic_flags": True} + kinds = [token[4:] for token in tokens if token[4:] not in {"UniqueConnection", "SingleShotConnection"}] + return {"declared_type": kinds[0] if len(kinds) == 1 else "AutoConnection" if not kinds else "", + "flags": [token[4:] for token in tokens if token[4:] in {"UniqueConnection", "SingleShotConnection"}], + "explicit_type": bool(kinds), "dynamic_flags": len(kinds) > 1} + + +def lambda_parameters(expression): + match = re.match(r"\s*\[[^]]*\]\s*(?:\(([^)]*)\))?", expression, re.S) + if not match: + return None + result = [] + for parameter in split_arguments((match[1] or "").encode()): + if not re.fullmatch(r"[\w:<>*&\s]+", parameter): + return None + value = re.sub(r"\s+[A-Za-z_]\w*\s*$", "", parameter) + result.append(normalize_type(value)) + return result diff --git a/graphify/extractors/qt_cpp_events.py b/graphify/extractors/qt_cpp_events.py new file mode 100644 index 0000000000..2162053975 --- /dev/null +++ b/graphify/extractors/qt_cpp_events.py @@ -0,0 +1,166 @@ +"""Qt C++ event occurrences; event delivery is never an ordinary calls edge.""" +from __future__ import annotations + +import re +from pathlib import Path + +from graphify.extractors.qt_cpp_calls import calls, conditional_at +from graphify.extractors.qt_cpp_access_patterns import assigned_handle, last_assignment +from graphify.extractors.qt_cpp_event_patterns import connection_flags, endpoint, lambda_parameters +from graphify.extractors.qt_cpp_exposure import is_qt_cpp_source +from graphify.extractors.qt_cpp_facts import QtFacts, owner_scope, qt_metadata, update_qt +from graphify.extractors.qt_cpp_mapping import map_cpp +from graphify.extractors.qt_cpp_identity import CppDeclarationIdentity +from graphify.extractors.qt_cpp_syntax import QtCppError, read_cpp +from graphify.extractors.qt_cpp_variables import simple_reference, variables_at +from graphify.extractors.qt_cpp_type_scope import NativeTypeScope + + +CPP_SUFFIXES = {".cpp", ".cc", ".cxx", ".c++", ".hpp", ".hh", ".hxx", ".h"} + + +def _failure(result, path, root, error): + try: + name = path.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + name = path.name + code = error.code if isinstance(error, QtCppError) else "QT_CPP_LIMIT" + result.setdefault("qt_failures", []).append({"code": code, "source_file": name}) + result.setdefault("diagnostics", []).append({"code": code, "source_file": name, "severity": "error", + "owner": "qt_cpp", "message": "Qt source analysis could not complete", "recovery": "Correct source and retry; prior graph is preserved."}) + + +def _qt_owner(mapping, owner): + classes = [record for record in mapping.classes if record.get("node_id") == owner.get("class_id")] + return any(any(macro["name"] == "Q_OBJECT" for macro in record.get("macros", [])) for record in classes) + + +def _typed_endpoint(expression, types, call): + """Member-pointer qualification obeys the same lexical type authority as locals.""" + value = endpoint(expression) + if value["form"] == "member_pointer": + raw_name = value["class_name"] + resolved = types.lookup(raw_name, call["start_byte"], owner=call["owner"]) + value.update(raw_class_name=raw_name, class_name=resolved.name) + if not resolved.name: + value.update(form="dynamic", type_reason=resolved.reason) + return value + + +def _connection(facts, unit, mapping, call, types, identity): + owner, args = call["owner"], call["args"] + if call.get("computed_receiver") or ("::" in call["callee"] and call["callee"] != "QObject::" + call["name"]): + return + if call["callee"] != "QObject::" + call["name"] and not _qt_owner(mapping, owner): + return + if call["callee"] == call["name"] and any(function["name"] == call["name"] and function.get("class_id") == owner.get("class_id") for function in mapping.functions): + return + variables = variables_at(unit, mapping, call["start_byte"], type_scope=types) + signal = _typed_endpoint(args[1]["text"], types, call) if len(args) >= 2 else endpoint("") + receiver, context, callable_arg, flag_arg = "", "", None, "" + if len(args) in {4, 5}: + receiver = simple_reference(args[2]["text"]) + context = receiver + callable_arg = args[3] + flag_arg = args[4]["text"] if len(args) == 5 else "" + elif len(args) == 3: + callable_arg = args[2] + callable_text = callable_arg["text"] if callable_arg else "" + parameters = lambda_parameters(callable_text) + receiver_endpoint = _typed_endpoint(callable_text, types, call) + form = "lambda" if parameters is not None else receiver_endpoint["form"] + callable_reference = simple_reference(callable_text) + if form == "dynamic" and callable_reference: + form = "functor" if variables.get(callable_reference) else "function" + sender = simple_reference(args[0]["text"]) if args else "" + sender_identity = identity.resolve(sender, call["start_byte"]) + receiver_identity = identity.resolve(receiver, call["start_byte"]) + site = facts.add(call["name"], call["name"], call["span"], owner.get("node_id"), + owner_class=owner.get("class_name") or "", owner_status=owner.get("status") or "unavailable", + owner_scope_key=owner_scope(unit, owner), + sender_reference=sender, sender_type=variables.get(sender, ""), signal=signal, + sender_declaration_id=sender_identity["declaration_id"], + receiver_reference=receiver, receiver_type=variables.get(receiver, ""), receiver=receiver_endpoint, + receiver_declaration_id=receiver_identity["declaration_id"], + context_reference=context, context_type=variables.get(context, ""), callable_form=form, + callable_reference=callable_reference, callable_type=variables.get(callable_reference, ""), + assigned_handle=assigned_handle(unit, call), + assignment_byte=last_assignment(unit, owner, assigned_handle(unit, call), call["start_byte"]), + handle_reference=simple_reference(args[0]["text"]) if call["name"] == "disconnect" and len(args) == 1 else "", + handle_assignment_byte=last_assignment(unit, owner, sender, call["start_byte"]), + sender_assignment_byte=last_assignment(unit, owner, sender, call["start_byte"]), + receiver_assignment_byte=last_assignment(unit, owner, receiver, call["start_byte"]), + signal_wildcard=len(args) > 1 and args[1]["text"] in {"nullptr", "0"}, + receiver_wildcard=len(args) > 2 and args[2]["text"] in {"nullptr", "0"}, + callable_wildcard=len(args) > 3 and args[3]["text"] in {"nullptr", "0"}, + conditional=conditional_at(unit, call["start_byte"]), + status="pending", reason="", runtime_delivery="unverified", **connection_flags(flag_arg)) + if parameters is not None and callable_arg: + target = facts.add("lambda", "connected lambda", callable_arg["span"], site, + parameter_types=parameters, callable_form="lambda") + update_qt(site, lambda_id=target["id"]) + + +def _emission(facts, unit, mapping, call, types, identity): + owner = call["owner"] + variables = variables_at(unit, mapping, call["start_byte"], type_scope=types) + receiver = call["receiver"] or ("" if call.get("computed_receiver") else "this") + # The scanner owns annotation authority; a rejected local macro override + # cannot regain that role through an unqualified source-prefix fallback. + explicit = call.get("explicit_emit") is True + if not explicit and not _qt_owner(mapping, owner) and not variables.get(receiver): + return + # Preserve independent typed receivers; an unbound constructor cannot infer + # a native `this` type solely from its syntactic class qualification. + receiver_type = variables.get(receiver, "") + if receiver == "this": + receiver_type = "" if owner.get("native_owner_unavailable") else receiver_type or owner.get("class_name", "") + facts.add("emission", call["name"], call["span"], owner.get("node_id"), + owner_class=owner.get("class_name") or "", receiver_reference=receiver, + owner_scope_key=owner_scope(unit, owner), + receiver_declaration_id=identity.resolve(receiver, call["start_byte"])["declaration_id"], + receiver_type=receiver_type, + member_name=call["name"], argument_count=len(call["args"]), explicit_emit=explicit, + conditional=conditional_at(unit, call["start_byte"]), status="pending", reason="") + + +def collect_qt_cpp_events(paths, per_file, *, root: Path, accepted_nodes=None, accepted_edges=None): + """Enrich admitted results after canonicalization; never regenerate C++ definitions.""" + nodes = accepted_nodes if accepted_nodes is not None else [n for r in per_file if r for n in r.get("nodes", [])] + edges = accepted_edges if accepted_edges is not None else [e for r in per_file if r for e in r.get("edges", [])] + signals = {qt_metadata(node).get("raw_name") for node in nodes if "signal" in qt_metadata(node).get("roles", [])} + for path, result in zip(paths, per_file): + if not result or Path(path).suffix.lower() not in CPP_SUFFIXES or result.get("error"): + continue + try: + if not is_qt_cpp_source(path): + continue + unit = read_cpp(Path(path), Path(root)) + mapping, facts = map_cpp(unit, nodes, edges, root=Path(root)), QtFacts(unit) + mapping.bind_classes([{"node_id": md.get("class_id", ""), "qualified_name": md.get("class_name", "")} + for node in nodes if (md := qt_metadata(node)).get("kind") == "class"]) + types = NativeTypeScope(unit, mapping) + identity = CppDeclarationIdentity(unit, mapping) + local_signals = {f["name"] for f in mapping.functions if "signal" in f.get("roles", [])} + for function in mapping.functions: + if not function.get("class_name") and function.get("node_id"): + facts.add("callable", function["qualified_name"], function["span"], function["node_id"], + generic_target_id=function["node_id"], parameter_types=function["parameter_types"], + callable_name=function["qualified_name"], status="resolved") + # Explicit accepted emission syntax survives missing declarations. + # The scanner's opt-in admits only those occurrences, preserving + # ordinary bare-call gating and every other collector's inventory. + for call in calls(unit, mapping, {"connect", "disconnect"} | signals | local_signals, include_explicit=True): + # Explicit signal syntax owns its mechanism even when a signal + # shares the spelling of a Qt connection API. + if call.get("explicit_emit") is True: + _emission(facts, unit, mapping, call, types, identity) + elif call["name"] in {"connect", "disconnect"}: + _connection(facts, unit, mapping, call, types, identity) + else: + _emission(facts, unit, mapping, call, types, identity) + known = {node["id"] for node in result.get("nodes", [])} + result.setdefault("nodes", []).extend(node for node in facts.nodes if node["id"] not in known) + result.setdefault("edges", []).extend(facts.edges) + except (QtCppError, ValueError) as error: + _failure(result, Path(path), Path(root), error) diff --git a/graphify/extractors/qt_cpp_exposure.py b/graphify/extractors/qt_cpp_exposure.py new file mode 100644 index 0000000000..9993200a66 --- /dev/null +++ b/graphify/extractors/qt_cpp_exposure.py @@ -0,0 +1,188 @@ +"""Append bounded Qt overlays to accepted C++; never replace generic declarations.""" +from __future__ import annotations + +from pathlib import Path +import re + +from graphify.extractors.qt_cpp_facts import QtFacts, qt_metadata +from graphify.extractors.qt_cpp_mapping import map_cpp +from graphify.extractors.qt_cpp_properties import add_properties +from graphify.extractors.qt_cpp_registration import add_literal_registrations, add_macro_registration, conditional_offset +from graphify.extractors.qt_cpp_syntax import MAX_CPP_BYTES, QtCppError, lexical_code, read_cpp +from graphify.extractors.qt_cpp_type_aliases import add_type_dependency_facts, type_dependency_source +from graphify.extractors.qt_cpp_type_scope import NativeTypeScope +from graphify.extractors.qt_cpp_api_types import add_api_fields, return_fields + +_CPP = {".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h"} +_QT = re.compile(rb"\b(?:QML_[A-Z_]+|Q_[A-Z_]+|QObject|QQml\w*|QQuickView|qmlRegister\w*|signals|slots)\b") + + +def is_qt_cpp_source(path, *, class_names=()): + if Path(path).suffix.lower() not in _CPP: + return False + try: + with Path(path).open("rb") as stream: + source = stream.read(MAX_CPP_BYTES + 1) + except OSError as exc: + raise QtCppError("QT_CPP_READ", "Cannot read admitted C++ source") from exc + code = lexical_code(source) + if _QT.search(code) or re.search(rb"\bemit\s+[A-Za-z_]", code): + return True + # Admission hints never bind an endpoint: parsed qualification and accepted + # ownership subsequently prove the actual class/method relationship. + return any(re.search(rb"\b" + re.escape(name.rsplit("::", 1)[-1].encode()) + rb"\s*::", code) + for name in class_names) + + +def _failure(result, path, root, error): + try: + source = Path(path).resolve().relative_to(Path(root).resolve()).as_posix() + except ValueError: + source = "" + result.setdefault("qml_failures", []).append({"code": error.code, "source_file": source}) + result.setdefault("diagnostics", []).append({"code": error.code, "severity": "error", "owner": "qt", + "source_file": source, "message": str(error)}) + + +def _class_facts(mapping, facts, *, classes=(), alias_nodes=None): + # Literal source occurrences can belong to a proven callable even when no + # Qt class definition is admitted. This is source containment only: it must + # not supply missing class authority, endpoint roles or QML visibility. + callables = {node["id"] for node in mapping.nodes if node.get("_callable") is True + and not node.get("_callable_class")} + types = NativeTypeScope(mapping.unit, mapping, classes=classes, alias_nodes=alias_nodes) + + def source_owner(record): + return record.get("node_id", "") if (record and record.get("status") == "resolved" + and record.get("node_id") in callables) else "" + + for record in mapping.classes: + owned = [macro for macro in record["macros"] if mapping.class_at(macro["start_byte"]) is record] + macros = {macro["name"] for macro in owned} + base = next((child for child in record["syntax"].named_children if child.type == "base_class_clause"), None) + bases = [mapping.unit.text(child) for child in base.named_children + if child.type not in {"access_specifier", "comment"}] if base else [] + # Inheritance visibility belongs to each source base, independently of + # member visibility. Each comma restores class/struct default access; + # virtual and comments cannot provide or replace an access specifier. + default_access = "public" if record["syntax"].type == "struct_specifier" else "private" + access, base_access = default_access, [] + for child in base.children if base else (): + if child.type == ",": + access = default_access + elif child.type == "access_specifier": + access = mapping.unit.text(child) + elif child.is_named and child.type != "comment": + base_access.append(access) + # Construction authority needs lexical base identities, not a Q_OBJECT + # marker or a guessed basename. Unknown header aliases remain opaque. + canonical_bases = [types.resolve(name, record["span"]["start_byte"]) for name in bases] + enclosing = mapping.owner_at(record["span"]["start_byte"]) + facts.add("class", record["name"], record["span"], + owner=record["node_id"] or source_owner(enclosing) or None, + generic_target_id=record["node_id"], class_id=record["node_id"], + class_name=record["qualified_name"], status=record["status"], + is_definition=record["is_definition"], + api_declaration_conditional=conditional_offset(mapping.unit, record["span"]["start_byte"]), + is_qobject="Q_OBJECT" in macros or "QObject" in bases, + source_q_object="Q_OBJECT" in macros, is_gadget="Q_GADGET" in macros, bases=bases, + canonical_base_names=canonical_bases, + canonical_base_status="resolved" if all(canonical_bases) else "unavailable", + canonical_base_access=base_access, + canonical_base_access_status="resolved" if len(base_access) == len(bases) + and all(value in {"public", "protected", "private"} for value in base_access) else "unavailable") + add_macro_registration(mapping, facts, record) + add_properties(mapping.unit, mapping, facts, record, types=types) + add_api_fields(mapping, facts, record, types) + for record in mapping.functions: + if not record["class_name"]: + continue + if len(record["parameters"]) > 50: + raise QtCppError("QT_CPP_LIMIT", "Qt member exceeds 50 parameters") + revisions = [macro["args"] for macro in mapping.unit.macros if macro["name"] == "Q_REVISION" + and macro["end_byte"] <= record["span"]["start_byte"] + and not mapping.unit.parsed_source[macro["end_byte"]:record["span"]["start_byte"]].strip()] + facts.add("member", record["name"], record["span"], + owner=record["class_id"] or source_owner(record) or None, + generic_target_id=record["node_id"], class_id=record["class_id"], class_name=record["class_name"], + signature=record["signature"], parameter_types=record["parameter_types"], + parameter_names=[item["name"] for item in record["parameters"]], + revisions=revisions, + return_type=record["return_type"], roles=record["roles"], access=record["access"], status=record["status"], + **return_fields(mapping.unit, record, types)) + + +def enrich_qt_cpp(paths, per_file, *, root, accepted_nodes=None, accepted_edges=None): + """Use final canonical aggregate IDs; mutate fresh result slices only. + + Failure facts share the QML safety channel so update can reject incomplete Qt + overlays. Borrowed aggregate/context dictionaries are never annotated. + """ + paths, per_file = list(paths), list(per_file) + nodes = list(accepted_nodes) if accepted_nodes is not None else [node for result in per_file for node in result.get("nodes", [])] + edges = list(accepted_edges) if accepted_edges is not None else [edge for result in per_file for edge in result.get("edges", [])] + units, pending = [], [] + for path, result in zip(paths, per_file): + try: + if Path(path).suffix.lower() not in _CPP: + continue + try: + Path(path).resolve().relative_to(Path(root).resolve()) + except ValueError as exc: + raise QtCppError("QT_CPP_ROOT", "Qt C++ source is outside the scan root") from exc + if not is_qt_cpp_source(path): + pending.append((path, result)) + continue + unit = read_cpp(Path(path), Path(root)) + units.append((map_cpp(unit, nodes, edges, root=Path(root)), result)) + except QtCppError as error: + _failure(result, path, root, error) + classes = [record for mapping, _ in units for record in mapping.classes] + # Borrowed complete bodies retain their source/span identity so binding can + # deduplicate the same accepted body reconstructed by the canonical mapper. + classes.extend({"node_id": md["class_id"], "qualified_name": md["class_name"], + "source_file": node.get("source_file"), "span": md.get("span", {}), + "is_definition": md.get("is_definition")} + for node in nodes if (md := qt_metadata(node)).get("kind") == "class" + and md.get("class_id") and md.get("class_name")) + for path, result in pending: + try: + if (is_qt_cpp_source(path, class_names=[item["qualified_name"] for item in classes]) + or ((units or classes) and type_dependency_source(path))): + unit = read_cpp(Path(path), Path(root)) + units.append((map_cpp(unit, nodes, edges, root=Path(root)), result)) + except QtCppError as error: + _failure(result, path, root, error) + # All accepted header shadows must exist before any source registration is + # resolved. This run-owned snapshot adds no include discovery or execution. + alias_nodes = list(nodes) + for mapping, result in units: + dependencies = QtFacts(mapping.unit) + try: + add_type_dependency_facts(mapping, dependencies) + except (QtCppError, ValueError): + _failure(result, mapping.unit.path, root, QtCppError("QT_CPP_LIMIT", "Type dependency facts are unavailable")) + continue + result.setdefault("nodes", []).extend(dependencies.nodes) + alias_nodes.extend(dependencies.nodes) + for mapping, result in units: + mapping.bind_classes(classes) + facts = QtFacts(mapping.unit) + try: + _class_facts(mapping, facts, classes=classes, alias_nodes=alias_nodes) + # Forward declarations remain source facts, but only a parsed body + # establishes a registration provider; old metadata proves neither. + add_literal_registrations(mapping, facts, [item for item in classes if item.get("is_definition") is True], + alias_nodes=alias_nodes) + except QtCppError as error: + _failure(result, mapping.unit.path, root, error) + continue + except ValueError: + _failure(result, mapping.unit.path, root, QtCppError("QT_CPP_LIMIT", "Qt metadata exceeds transport bounds")) + continue + result.setdefault("nodes", []).extend(facts.nodes) + result.setdefault("edges", []).extend(facts.edges) + return {"nodes": [node for _, result in units for node in result.get("nodes", []) + if "qt" in node.get("metadata", {})], + "failures": [failure for result in per_file for failure in result.get("qml_failures", []) + if failure.get("code", "").startswith("QT_CPP_")]} diff --git a/graphify/extractors/qt_cpp_facts.py b/graphify/extractors/qt_cpp_facts.py new file mode 100644 index 0000000000..2bbf0219f1 --- /dev/null +++ b/graphify/extractors/qt_cpp_facts.py @@ -0,0 +1,124 @@ +"""Portable source-owned Qt facts with literal, bounded metadata transport.""" +from __future__ import annotations + +import base64 +import hashlib +import json + +from graphify.ids import make_id + + +def encode_qt(values: dict) -> dict: + """Display sanitation and semantic identity have separate representations.""" + literals = {} + def visit(path, value): + if isinstance(value, str): + encoded = base64.b64encode(value.encode()).decode("ascii") + if len(encoded) > 512: + raise ValueError("QT_LIMIT: semantic field exceeds transport bound") + literals["/".join(path)] = encoded + elif isinstance(value, dict): + for key, child in value.items(): + visit([*path, str(key)], child) + elif isinstance(value, list): + if len(value) > 50: + raise ValueError("QT_LIMIT: semantic list exceeds transport bound") + for index, child in enumerate(value): + visit([*path, str(index)], child) + for key, value in values.items(): + visit([key], value) + if len(literals) > 100: + raise ValueError("QT_LIMIT: semantic fields exceed transport bound") + return {"contract_version": 1, **values, "raw_values": literals} + + +def qt_metadata(node: dict) -> dict: + """Reject corrupted transport instead of interpreting escaped display strings.""" + value = node.get("metadata", {}).get("qt", {}) + if not isinstance(value, dict) or value.get("contract_version") != 1: + return {} + result = json.loads(json.dumps(value)) + literals = result.get("raw_values", {}) + if not isinstance(literals, dict) or len(literals) > 100: + raise ValueError("QT_METADATA: malformed literal transport") + for key, encoded in literals.items(): + if not isinstance(key, str) or not isinstance(encoded, str) or len(encoded) > 512: + raise ValueError("QT_METADATA: malformed literal field") + try: + decoded = base64.b64decode(encoded, validate=True).decode("utf-8") + parts, current = key.split("/"), result + for part in parts[:-1]: + current = current[int(part)] if isinstance(current, list) else current[part] + if isinstance(current, list): + current[int(parts[-1])] = decoded + else: + current[parts[-1]] = decoded + except (ValueError, UnicodeError, KeyError, IndexError, TypeError) as exc: + raise ValueError("QT_METADATA: malformed literal field") from exc + return result + + +def source_span(syntax) -> dict: + if isinstance(syntax, dict): + return dict(syntax) + return {"start_byte": syntax.start_byte, "end_byte": syntax.end_byte, + "start_row": syntax.start_point.row, "end_row": syntax.end_point.row, + "start_column": syntax.start_point.column, "end_column": syntax.end_point.column} + + +def qt_id(file: str, kind: str, semantic_key) -> str: + exact = json.dumps([file, kind, semantic_key], ensure_ascii=False, separators=(",", ":")) + return make_id("qt", file, kind, hashlib.sha256(exact.encode()).hexdigest()) + + +def owner_scope(unit, owner): + return qt_id(unit.relative_file, "owner_scope", [owner.get("qualified_name"), owner.get("signature"), owner["span"]["start_byte"], owner["span"]["end_byte"]]) + + +def scope_owner(metadata): + return metadata.get("owner_scope_key") or metadata.get("owner_id") + + +class QtFacts: + """One accepted source owns facts; canonical C++ nodes are borrowed unchanged.""" + + def __init__(self, unit): + self.unit, self.file = unit, unit.relative_file + self.nodes, self.edges = [], [] + + def add(self, kind, name, syntax, owner=None, **fields): + span = source_span(syntax) + owner_id = owner.get("id") if isinstance(owner, dict) else owner or "" + semantic = fields.pop("semantic_key", [owner_id, span["start_byte"], span["end_byte"], name]) + node = {"id": qt_id(self.file, kind, semantic), "label": f"Qt {kind}: {name}", + "type": "concept", "file_type": "code", "source_file": self.file, + "source_location": f'L{span["start_row"] + 1}-L{span["end_row"] + 1}', "_origin": "ast", + "metadata": {"qt": encode_qt({"kind": kind, "raw_name": name, "span": span, + "owner_id": owner_id, **fields})}} + self.nodes.append(node) + if owner_id: + self.edge(owner_id, node["id"], "contains", span, context="qt_source_site") + return node + + def edge(self, source, target, relation, syntax, *, context, confidence="EXTRACTED", **fields): + span = source_span(syntax) + edge = {"source": source, "target": target, "_src": source, "_tgt": target, + "relation": relation, "confidence": confidence, "context": context, + "source_file": self.file, "source_location": f'L{span["start_row"] + 1}-L{span["end_row"] + 1}', + "metadata": {"qt": encode_qt({"span": span, "target_id": target, **fields})}} + self.edges.append(edge) + return edge + + +def update_qt(node, **fields) -> None: + values = {k: v for k, v in qt_metadata(node).items() if k != "raw_values"} + node["metadata"]["qt"] = encode_qt({**values, **fields}) + + +def qt_edge(site, target, relation, context, **fields): + """Source direction survives Graphify's undirected graph and raw JSON reload.""" + span = qt_metadata(site).get("span", {}) + return {"source": site["id"], "target": target, "_src": site["id"], "_tgt": target, + "relation": relation, "confidence": "INFERRED", "context": context, + "source_file": site["source_file"], "source_location": site["source_location"], + "metadata": {"qt": encode_qt({"span": span, "target_id": target, **fields})}} diff --git a/graphify/extractors/qt_cpp_identity.py b/graphify/extractors/qt_cpp_identity.py new file mode 100644 index 0000000000..a2250608a6 --- /dev/null +++ b/graphify/extractors/qt_cpp_identity.py @@ -0,0 +1,182 @@ +"""Source-local declaration identities for Qt loaders, providers and handles. + +One index belongs to one accepted C++ syntax/mapping pair. Identities prove a +lexical declaration, not runtime object equality, aliases or pointer dataflow. +""" +from __future__ import annotations + +import hashlib +import json +import re + +from graphify.extractors.qt_cpp_facts import scope_owner +from graphify.extractors.qt_cpp_syntax import source_span, walk + +_SCOPES = {"compound_statement", "for_statement", "for_range_loop", "if_statement", + "switch_statement", "while_statement", "catch_clause", "lambda_expression"} +_CONDITIONS = {"if_statement", "switch_statement", "conditional_expression", "for_statement", + "for_range_loop", "while_statement", "do_statement", "catch_clause", + "lambda_expression", "preproc_if", "preproc_ifdef", "preproc_else"} +_DECLARATIONS = {"declaration", "parameter_declaration", "optional_parameter_declaration", + "variadic_parameter_declaration", "for_range_loop"} + + +def _identifier(node): + """Unwrap declarators only; fields, factories and structured bindings stay opaque.""" + while node is not None and node.type in {"pointer_declarator", "reference_declarator", + "parenthesized_declarator", "init_declarator"}: + node = node.child_by_field_name("declarator") or next(iter(node.named_children), None) + return node if node is not None and node.type == "identifier" else None + + +def _ancestors(node): + while node is not None: + yield node + node = node.parent + + +def _inside(node, position): + return node.start_byte <= position < node.end_byte + + +def _answer(status, reason="", identity="", declaration_span=None, scope_span=None): + return {"declaration_id": identity, "status": status, "reason": reason, + "declaration_span": declaration_span or {}, "scope_span": scope_span or {}} + + +class CppDeclarationIdentity: + """Bounded AST index reused by every reference in a single extraction pass.""" + + def __init__(self, unit, mapping): + self.unit, self.mapping = unit, mapping + self.declarations, self.writes, self.conditions = {}, [], [] + for syntax in walk(unit.tree): + if syntax.type in _CONDITIONS: + self.conditions.append(syntax) + if syntax.type in {"assignment_expression", "update_expression"}: + left = syntax.child_by_field_name("left") or syntax.child_by_field_name("argument") + if left is not None and left.type == "identifier": + self.writes.append((unit.text(left), syntax)) + if syntax.type not in _DECLARATIONS: + continue + for index, child in enumerate(syntax.named_children): + if syntax.field_name_for_named_child(index) != "declarator": + continue + name = _identifier(child) + if name is None: + continue + owner = mapping.owner_at(name.start_byte) + if not owner or owner.get("body") is None: + continue + scope = next((parent for parent in _ancestors(syntax.parent) + if parent.type in _SCOPES), None) + if syntax.type == "for_range_loop": + scope = syntax + # Function parameters and top-level locals share the function + # body's scope; illegal same-scope duplicates remain ambiguous. + scope = scope or owner["body"] + if not (owner["span"]["start_byte"] <= scope.start_byte + and scope.end_byte <= owner["span"]["end_byte"]): + scope = owner["body"] + record = {"name": unit.text(name), "name_end": name.end_byte, "syntax": child, + "scope": scope, "owner": owner, + "conditional": any(parent.type in _CONDITIONS for parent in _ancestors(syntax)), + "depth": sum(1 for _ in _ancestors(scope))} + self.declarations.setdefault(record["name"], []).append(record) + + def _select(self, name, position, owner): + candidates = [record for record in self.declarations.get(name, []) + if record["owner"] is owner and record["name_end"] <= position + and _inside(record["scope"], position)] + if not candidates: + return None, "declaration_unavailable" + depth = max(record["depth"] for record in candidates) + nearest = [record for record in candidates if record["depth"] == depth] + return (nearest[0], "") if len(nearest) == 1 else (None, "declaration_ambiguous") + + def _identity(self, name, owner, declaration, scope): + key = [self.unit.relative_file, owner.get("qualified_name"), owner.get("signature"), + owner["span"]["start_byte"], owner["span"]["end_byte"], name, + declaration.start_byte, declaration.end_byte, scope.start_byte, scope.end_byte] + return hashlib.sha256(json.dumps(key, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() + + def type_binding(self, name, position): + """Original declaration spelling owns type authority, before use-site aliases. + + A normalized variable type loses an explicit global qualifier. Resolve + its source binding instead of treating that display spelling as a new + type expression at the later call site. + """ + # Runtime lifetime/assignment uncertainty is reported separately by + # resolve(); it must not erase the observed SDK operation itself. + if name == "this": + return "", position + owner = self.mapping.owner_at(position) + record, _ = self._select(name, position, owner) + if record is None: + return "", position + declaration = record["syntax"].parent + if declaration is None or declaration.type not in _DECLARATIONS: + return "", position + return self.unit.field(declaration, "type"), declaration.start_byte + + def declared_type_binding(self, name, position): + """API roots retain original declarator shape without changing SDK lookup.""" + from graphify.extractors.qt_cpp_api_shape import declared_spelling + owner = self.mapping.owner_at(position) + record, _ = self._select(name, position, owner) + if record is None: + return "", position + declarator = record["syntax"] + declaration = declarator.parent + if declaration is None or declaration.type not in _DECLARATIONS: + return "", position + return declared_spelling(self.unit, declaration, declarator, _identifier(declarator)), declaration.start_byte + + def resolve(self, name, position): + """Return a portable 64-hex ID or explicit uncertainty, never a name fallback.""" + if not isinstance(name, str) or not re.fullmatch(r"this|[A-Za-z_]\w*", name): + return _answer("unsupported", "reference_expression_unsupported") + owner = self.mapping.owner_at(position) + if not owner or owner.get("body") is None or not _inside(owner["body"], position): + return _answer("unavailable", "source_function_unavailable") + if any(_inside(condition, position) for condition in self.conditions): + return _answer("dynamic", "conditional_declaration_use") + if name == "this": + if not owner.get("class_id") or owner.get("native_owner_unavailable"): + return _answer("unavailable", "this_class_unavailable") + declaration, scope = owner["syntax"], owner["body"] + return _answer("resolved", identity=self._identity(name, owner, declaration, scope), + declaration_span=owner["span"], + scope_span=source_span(self.unit.source, scope.start_byte, scope.end_byte)) + record, reason = self._select(name, position, owner) + if record is None: + return _answer("ambiguous" if reason == "declaration_ambiguous" else "unavailable", reason) + if record["conditional"]: + return _answer("dynamic", "conditional_declaration") + # A write belongs to the declaration visible at that write, not every + # variable with the same spelling elsewhere in this function. + for written, syntax in self.writes: + if written == name and record["name_end"] <= syntax.start_byte < position: + selected, _ = self._select(name, syntax.start_byte, owner) + if selected is record: + return _answer("dynamic", "reassigned_declaration") + declaration, scope = record["syntax"], record["scope"] + return _answer("resolved", identity=self._identity(name, owner, declaration, scope), + declaration_span=source_span(self.unit.source, declaration.start_byte, declaration.end_byte), + scope_span=source_span(self.unit.source, scope.start_byte, scope.end_byte)) + + +def reference_identity(unit, mapping, name, position): + """Convenience seam; collectors should reuse CppDeclarationIdentity per file.""" + return CppDeclarationIdentity(unit, mapping).resolve(name, position) + + +def source_reference_key(metadata, role="receiver"): + """Accepted fact transport must carry an identity; legacy names cannot join.""" + if role not in {"receiver", "assigned", "engine", "provider", "sender"}: + return None + identity, owner = metadata.get(role + "_declaration_id"), scope_owner(metadata) + if not isinstance(identity, str) or not re.fullmatch(r"[0-9a-f]{64}", identity): + return None + return (owner, identity) if isinstance(owner, str) and owner else None diff --git a/graphify/extractors/qt_cpp_loaders.py b/graphify/extractors/qt_cpp_loaders.py new file mode 100644 index 0000000000..5c7c0c57b6 --- /dev/null +++ b/graphify/extractors/qt_cpp_loaders.py @@ -0,0 +1,163 @@ +"""Bounded literal URL constructors; loading never executes an analyzed engine.""" +from __future__ import annotations + +import re +from pathlib import Path + +from graphify.extractors.qt_cpp_calls import argument_ranges, closing, conditional_at, literal_string +from graphify.extractors.qt_cpp_facts import owner_scope +from graphify.extractors.qt_cpp_syntax import source_span +from graphify.extractors.qt_cpp_variables import simple_reference, type_name, variables_at + +_MODES = {"QQmlComponent::PreferSynchronous", "QQmlComponent::Asynchronous"} + + +def declared_sdk_type(identities, types, name, position, expected): + """Receiver declarations retain global/alias authority independently of display.""" + spelling, declared_at = identities.type_binding(name, position) + return bool(spelling and types.sdk_type(type_name(spelling), declared_at, expected)) + + +def loader_operation_owner(receiver_type, operation): + """Only exact supported SDK classes own these source load/root mechanisms.""" + owners = {"load": "QQmlApplicationEngine", "loadFromModule": "QQmlApplicationEngine", + "rootObjects": "QQmlApplicationEngine", "setInitialProperties": "QQmlApplicationEngine", + "setSource": "QQuickView", "rootObject": "QQuickView", "loadUrl": "QQmlComponent", + "create": "QQmlComponent", "createWithInitialProperties": "QQmlComponent"} + kind = receiver_type.removeprefix("::") + return kind in {"QQmlApplicationEngine", "QQuickView"} if operation == "setInitialProperties" else owners.get(operation) == kind + + +def literal_loader_arguments(receiver_type, operation, args, *, types=None, position=0): + """Distinguish URL methods and literal compilation modes from other overloads.""" + kind = receiver_type.removeprefix("::") + supported = {"load": "QQmlApplicationEngine", "loadFromModule": "QQmlApplicationEngine", + "setSource": "QQuickView", "loadUrl": "QQmlComponent"} + if supported.get(operation) != kind: + return False + if operation == "loadFromModule": + return len(args) == 2 + if len(args) == 1: + return True + return (operation == "loadUrl" and len(args) == 2 and args[1]["text"].strip() in _MODES + and (types is None or types.sdk_type("QQmlComponent", position, "QQmlComponent"))) + + +def literal_creation_arguments(operation, args): + """A supplied context can alter source base/ownership and remains unproved.""" + if operation in {"rootObject", "rootObjects"}: + return not args + if operation == "create": + return not args or len(args) == 1 and args[0]["text"].strip() in {"nullptr", "0"} + if operation == "createWithInitialProperties": + return len(args) == 1 or len(args) == 2 and args[1]["text"].strip() in {"nullptr", "0"} + return len(args) == 1 + + +def literal_url_expression(expression, *, types=None, identities=None, position=0): + """Keep fromLocalFile semantics: a resource URI is not an absolute file path. + + Relative file paths need the application's unknown runtime working directory; + conservative omission avoids lending the analyzer root as that directory. + """ + if types is not None: + # Every stripped wrapper needs source authority; a custom callable may + # return another URL despite having a literal written argument. + for wrapper in re.findall(r"(? str: + """Whitespace equivalence only; aliases and conversions require separate evidence.""" + return re.sub(r"\s*([*&<>,:])\s*", r"\1", re.sub(r"\s+", " ", value).strip()) + + +def _name(unit, syntax): + if syntax is None: + return "" + if syntax.type in {"identifier", "field_identifier", "type_identifier", "qualified_identifier", "destructor_name", "operator_name"}: + return unit.text(syntax) + child = syntax.child_by_field_name("declarator") + if child is None and syntax.type in {"reference_declarator", "pointer_declarator", "parenthesized_declarator", "array_declarator"}: + child = syntax.named_children[-1] if syntax.named_children else None + return _name(unit, child) if child else "" + + +def _qualified(unit, syntax, name): + scopes, parent = [], syntax.parent + while parent: + if parent.type in {"namespace_definition", "class_specifier", "struct_specifier"}: + scope = unit.field(parent, "name") + if scope: + scopes.append(scope) + parent = parent.parent + return "::".join(list(reversed(scopes)) + [name]) + + +def _line(node): + match = re.match(r"L(\d+)", str(node.get("source_location") or "")) + return int(match[1]) if match else None + + +def _label(node): + return str(node.get("label") or "").removesuffix("()").lstrip(".").split("::")[-1] + + +def _parameters(unit, declarator): + params = declarator.child_by_field_name("parameters") + values = [] + for syntax in params.named_children if params else []: + if syntax.type not in {"parameter_declaration", "optional_parameter_declaration", "variadic_parameter_declaration"}: + continue + declaration = syntax.child_by_field_name("declarator") + name = _name(unit, declaration) + text = unit.text(syntax).split("=", 1)[0].strip() + if name: + position = text.rfind(name) + text = text[:position] + text[position + len(name):] + values.append({"name": name, "type": normalize_type(text)}) + return [] if values == [{"name": "", "type": "void"}] else values + + +def _roles(unit, declaration, class_syntax): + access = "private" if class_syntax and class_syntax.type == "class_specifier" else "public" + roles = [] + if class_syntax: + body = class_syntax.child_by_field_name("body") + previous = class_syntax.start_byte + for child in body.named_children if body else []: + if child.start_byte >= declaration.start_byte: + break + if child.type == "access_specifier": + literal = unit.code[child.start_byte:child.end_byte].decode().strip() + access = "private" if "private" in literal else "protected" if "protected" in literal else "public" + gap_end = unit.code.find(b":", child.end_byte) + text = unit.code[child.start_byte:gap_end + 1].decode() if gap_end >= 0 else literal + roles = ["signal"] if re.search(r"\b(?:signals|Q_SIGNALS)\b", text) else ["slot"] if re.search(r"\b(?:slots|Q_SLOTS)\b", text) else [] + previous = max(previous, child.end_byte) + prefix = unit.code[previous:declaration.start_byte].decode() + if re.search(r"\b(?:Q_SIGNAL)\b", prefix): + roles = list(set(roles + ["signal"])) + if re.search(r"\b(?:Q_SLOT)\b", prefix): + roles = list(set(roles + ["slot"])) + if re.search(r"\bQ_INVOKABLE\b", prefix): + roles = list(set(roles + ["invokable"])) + return sorted(roles), access + + +class CppMapping: + """Borrowed generic nodes remain unchanged; source records retain ambiguity.""" + + def __init__(self, unit, nodes, edges, *, root): + self.unit, self.nodes, self.edges = unit, list(nodes), list(edges) + self.classes, self.functions = [], [] + self.paths = {id(node): source_path(node, root) for node in self.nodes} + self.definition_paths = {id(node): source_path({"source_file": node.get("definition_file")}, root) + for node in self.nodes} + self.parents = {} + self.class_parents = {} + accepted = {node["id"]: node for node in self.nodes} + self.accepted = accepted + for edge in self.edges: + if edge.get("relation") in {"method", "contains"}: + self.parents.setdefault(edge["target"], set()).add(edge["source"]) + parent = accepted.get(edge["source"], {}) + if (edge.get("confidence") == "EXTRACTED" and parent.get("_callable_class") is True + and source_path(edge, root) == source_path(parent, root)): + self.class_parents.setdefault(edge["target"], set()).add(edge["source"]) + syntax_nodes = list(walk(unit.tree)) + for syntax in syntax_nodes: + if syntax.type not in {"class_specifier", "struct_specifier"}: + continue + name = unit.field(syntax, "name") + qualified = _qualified(unit, syntax, name) + matches = [node["id"] for node in self.nodes if self.paths[id(node)] == unit.relative_file + and class_matches(node, qualified, syntax)] + record = self._record(syntax, name, qualified, matches) + if any(class_fact(node).get("ambiguous") for node in self.nodes if node["id"] in matches): + record.update(status="ambiguous", node_id="") + record["class_id"] = record["node_id"] + record["is_definition"] = syntax.child_by_field_name("body") is not None + record["macros"] = [macro for macro in unit.macros if syntax.start_byte <= macro["start_byte"] < syntax.end_byte] + self.classes.append(record) + # A generic producer collision is not evidence that two namespaces are one. + for record in self.classes: + others = [item for item in self.classes if item["node_id"] == record["node_id"] and item["qualified_name"] != record["qualified_name"]] + if record["node_id"] and others: + record.update(status="ambiguous", node_id="", class_id="") + for syntax in syntax_nodes: + if syntax.type != "function_declarator": + continue + self._function(syntax) + + def _record(self, syntax, name, qualified, matches): + candidates = sorted(set(matches)) + return {"syntax": syntax, "span": source_span(self.unit.source, syntax.start_byte, syntax.end_byte), + "name": name, "qualified_name": qualified, "source_file": self.unit.relative_file, + "node_id": candidates[0] if len(candidates) == 1 else "", + "status": "resolved" if len(candidates) == 1 else "ambiguous" if candidates else "unavailable", + "candidates": candidates[:50]} + + def _function(self, declarator): + declaration = declarator.parent + while declaration and declaration.type not in {"function_definition", "field_declaration", "declaration"}: + declaration = declaration.parent + if declaration is None: + return + name = _name(self.unit, declarator.child_by_field_name("declarator")) + if not name: + return + class_record = self.class_at(declaration.start_byte) + short, class_id = name.split("::")[-1], class_record["node_id"] if class_record else "" + # A shared constructor label must not select a neighboring overload. + # Use generic producer authority with the actual original occurrence. + constructor_owner = (class_record["qualified_name"] if class_record and short == class_record["name"] + else _qualified(self.unit, declaration, name).rsplit("::", 1)[0] + if "::" in name and name.split("::")[-2] == short else "") + constructor = bool(constructor_owner and declaration.child_by_field_name("type") is None) + matches = [node["id"] for node in self.nodes if _label(node) == short and node.get("_callable") + and ((class_id and class_id in self.parents.get(node["id"], ())) or + (not class_id and self.paths[id(node)] == self.unit.relative_file and _line(node) == declarator.start_point.row + 1)) + and (not constructor or syntax_matches(node, declaration, self.unit.source, constructor_owner))] + # Canonical header merges retain the implementation's exact file/line. + # That source identity is stronger than a name-only class lookup, and + # avoids losing ownership after source_file moves back to the header. + definition_matches = [node["id"] for node in self.nodes if _label(node) == short + and node.get("_callable") is True and not node.get("_callable_class") + and self.definition_paths[id(node)] == self.unit.relative_file + and _line({"source_location": node.get("definition_location")}) == declaration.start_point.row + 1 + and (not constructor or syntax_matches(node, declaration, self.unit.source, constructor_owner, definition_site=True))] + if not class_id and definition_matches: + matches = definition_matches + if not class_id and "::" in name and not definition_matches: + owners = {owner for candidate in matches for owner in self.parents.get(candidate, ())} + wanted = name.split("::")[-2] + owners = {node["id"] for node in self.nodes if node["id"] in owners and _label(node) == wanted and node.get("_callable_class")} + class_id = next(iter(owners)) if len(owners) == 1 else "" + qualified = _qualified(self.unit, declaration, name) + record = self._record(declaration, short, qualified, matches) + parameters = _parameters(self.unit, declarator) + roles, access = _roles(self.unit, declaration, class_record["syntax"] if class_record else None) + record.update(class_id=class_id, class_name=class_record["qualified_name"] if class_record else qualified.rsplit("::", 1)[0] if "::" in qualified else "", + definition_owners=sorted({owner for candidate in definition_matches for owner in self.class_parents.get(candidate, ())}), + parameters=parameters, parameter_types=[item["type"] for item in parameters], + return_type=normalize_type(self.unit.field(declaration, "type")), roles=roles, access=access, + body=declaration.child_by_field_name("body"), signature=short + "(" + ",".join(item["type"] for item in parameters) + ")") + record["out_of_line_constructor"] = ( + class_record is None and declaration.type == "function_definition" + and not record["return_type"] and "::" in name and name.split("::")[-2] == short) + record["constructor"] = constructor + self.functions.append(record) + + def owner_at(self, byte): + matches = [record for record in self.functions if record["span"]["start_byte"] <= byte < record["span"]["end_byte"]] + return min(matches, key=lambda record: record["span"]["end_byte"] - record["span"]["start_byte"]) if matches else None + + def bind_classes(self, classes): + """Join exact implementation evidence to a sole accepted complete class. + + Forward declarations remain source facts but do not compete with bodies. + Legacy metadata without an explicit body flag cannot prove completeness. + """ + definitions = {} + for item in list(classes) + [ + {"node_id": md.get("class_id"), "qualified_name": md.get("class_name"), + "source_file": node.get("source_file"), "span": md.get("span", {}), + "is_definition": md.get("is_definition")} + for node in self.nodes if (md := qt_metadata(node)).get("kind") == "class" + ]: + if item.get("is_definition") is not True or not item.get("node_id"): + continue + span = item.get("span", {}) + identity = (item["node_id"], item["qualified_name"], item.get("source_file"), + span.get("start_byte"), span.get("end_byte")) + definitions[identity] = item + for record in self.functions: + # A constructor's source callable is independent of class authority. + # Never replace a rejected/legacy constructor body with a same-name + # header prototype after generic canonicalization declined the join. + if record["out_of_line_constructor"] or record.get("constructor"): + targets = [self.accepted[candidate] for candidate in record["candidates"] + if candidate in self.accepted] + if (len(targets) != 1 or not targets[0].get("metadata", {}).get("cpp_constructor") + or not constructor_class_authorized(targets[0])): + record.update(class_id="", native_owner_unavailable=True) + continue + if record["class_id"] or not record["class_name"]: + continue + owners = record["definition_owners"] + found = [item for item in definitions.values() + if (item["node_id"] in owners if owners else item["qualified_name"] == record["class_name"])] + if len(found) != 1: + continue + definition = found[0] + # A conflicting real body invalidates an apparently unique parent; + # explicit foreign qualification cannot reuse another class's owner. + name = definition["qualified_name"] + if (sum(item["qualified_name"] == name for item in definitions.values()) != 1 + or (record["class_name"] != name and ("::" in record["class_name"] + or record["class_name"] != name.rsplit("::", 1)[-1]))): + continue + class_id = definition["node_id"] + candidates = {node["id"] for node in self.nodes if node.get("_callable") and _label(node) == record["name"] + and class_id in self.class_parents.get(node["id"], ()) and constructor_class_authorized(node)} + if owners: + candidates.intersection_update(record["candidates"]) + record.update(class_id=class_id, class_name=name, node_id=next(iter(candidates)) if len(candidates) == 1 else "", + status="resolved" if len(candidates) == 1 else "ambiguous" if candidates else "unavailable") + + def class_at(self, byte): + matches = [record for record in self.classes if record["span"]["start_byte"] <= byte < record["span"]["end_byte"]] + if matches: + return min(matches, key=lambda record: record["span"]["end_byte"] - record["span"]["start_byte"]) + return None + + +def map_cpp(unit, accepted_nodes, accepted_edges, *, root): + return CppMapping(unit, accepted_nodes, accepted_edges, root=root) diff --git a/graphify/extractors/qt_cpp_properties.py b/graphify/extractors/qt_cpp_properties.py new file mode 100644 index 0000000000..532f41f369 --- /dev/null +++ b/graphify/extractors/qt_cpp_properties.py @@ -0,0 +1,71 @@ +"""Source-backed Q_PROPERTY declarations and narrowly evidenced accessor links.""" +from __future__ import annotations + +import re + +from graphify.extractors.qt_cpp_mapping import normalize_type +from graphify.extractors.qt_cpp_registration import conditional_offset +from graphify.extractors.qt_cpp_api_types import api_type_fields + +_ATTRIBUTE = re.compile(r"\b(READ|WRITE|MEMBER|RESET|NOTIFY|REVISION|DESIGNABLE|SCRIPTABLE|STORED|USER|BINDABLE|CONSTANT|FINAL|REQUIRED)\b") + + +def property_fields(macro): + """Keep unsupported expressions literal; never expand a preprocessor wrapper.""" + text = macro["args"][0] if len(macro["args"]) == 1 else "" + tokens = list(_ATTRIBUTE.finditer(text)) + head = text[:tokens[0].start()] if tokens else text + match = re.fullmatch(r"\s*(.+?)(?:\s+|(?<=[*&]))([A-Za-z_]\w*)\s*", head) + if not match: + return {"status": "unsupported", "reason": "property_declaration_unsupported", "raw_name": ""} + fields = {"raw_name": match[2], "raw_type": normalize_type(match[1]), "status": "resolved", "reason": ""} + for index, token in enumerate(tokens): + value = text[token.end():tokens[index + 1].start() if index + 1 < len(tokens) else len(text)].strip() + key = token[1].lower() + if key in fields: + fields.update(status="unsupported", reason="duplicate_property_attribute") + fields[key] = value or True + if not fields.get("read") and not fields.get("member"): + fields.update(status="unsupported", reason="property_read_or_member_missing") + return fields + + +def add_properties(unit, mapping, facts, class_record, *, types=None): + """Properties are independent Qt facts; generic accessor IDs are borrowed.""" + for macro in class_record["macros"]: + if macro["name"] != "Q_PROPERTY" or mapping.class_at(macro["start_byte"]) is not class_record: + continue + fields = property_fields(macro) + if conditional_offset(unit, macro["start_byte"]): + fields.update(status="dynamic", reason="conditional_property_declaration") + name = fields.pop("raw_name") + if types is not None: + fields.update(api_type_fields(types, fields.get("raw_type", ""), macro["start_byte"], + conditional=conditional_offset(unit, macro["start_byte"]))) + site = facts.add("property", name, macro["span"], owner=class_record["node_id"] or None, + class_id=class_record["node_id"], class_name=class_record["qualified_name"], + generic_target_id="", **fields) + for role in ("read", "write", "reset", "notify"): + accessor = fields.get(role) + if not isinstance(accessor, str): + continue + candidates = [item for item in mapping.functions if item["class_id"] == class_record["node_id"] + and item["name"] == accessor and item["node_id"]] + if role == "notify": + candidates = [item for item in candidates if "signal" in item["roles"] + and item["return_type"] == "void" and item["parameter_types"] in + ([], [fields.get("raw_type")])] + elif role in {"read", "reset"}: + candidates = [item for item in candidates if not item["parameter_types"]] + elif role == "write": + candidates = [item for item in candidates if len(item["parameter_types"]) == 1] + signatures = {item["signature"] for item in candidates} + ids = {item["node_id"] for item in candidates} + status = "resolved" if len(ids) == len(signatures) == 1 else "ambiguous" if candidates else "unavailable" + link = facts.add("property_accessor", accessor, macro["span"], owner=site["id"], + semantic_key=[site["id"], role], property_id=site["id"], accessor_role=role, + status=status, reason="" if status == "resolved" else "accessor_not_unique_or_unavailable", + generic_target_id=next(iter(ids)) if status == "resolved" else "") + if status == "resolved": + facts.edge(link["id"], next(iter(ids)), "references", macro["span"], + context="qt_property_accessor", property_id=site["id"], accessor_role=role) diff --git a/graphify/extractors/qt_cpp_provider_expression.py b/graphify/extractors/qt_cpp_provider_expression.py new file mode 100644 index 0000000000..8bd33204d6 --- /dev/null +++ b/graphify/extractors/qt_cpp_provider_expression.py @@ -0,0 +1,29 @@ +"""Literal provider expression provenance rooted in one lexical declaration.""" +from __future__ import annotations + +import re + +from graphify.extractors.qt_cpp_api_shape import api_shape + + +def provider_expression(expression, identities, types, position): + """Bounded zero-argument public API chains never execute a getter or factory.""" + match = re.fullmatch(r"\s*&?\s*(this|[A-Za-z_]\w*)((?:\s*(?:->|\.)\s*[A-Za-z_]\w*\s*(?:\(\s*\))?)*)\s*", expression) + if not match or len(expression.encode()) > 384: + return {} + reference = match[1] + steps = [{"operator": item[1], "name": item[2], "role": "call" if item[3] else "field"} + for item in re.finditer(r"(->|\.)\s*([A-Za-z_]\w*)\s*(\(\s*\))?", match[2])] + if len(steps) > 8: + return {} + spelling, declared_at = identities.declared_type_binding(reference, position) + if reference == "this": + owner = identities.mapping.owner_at(position) + spelling, declared_at = (owner.get("class_name", "") + "*", owner["span"]["start_byte"]) if owner else ("", position) + shape = api_shape(spelling, allow_value=True) + ids, reason = types.resolve_class(shape[0], declared_at) if shape else ([], "provider_type_shape_unsupported") + return {"provider_reference": reference, "provider_expression_steps": steps, + "provider_root_type_spelling": spelling, "provider_root_address_of": expression.lstrip().startswith("&"), + "provider_root_type_id": ids[0] if len(ids) == 1 and not reason else "", + "provider_type_reason": reason, + "provider_type_status": "resolved" if len(ids) == 1 and not reason else "unsupported" if not shape else "unavailable"} diff --git a/graphify/extractors/qt_cpp_registration.py b/graphify/extractors/qt_cpp_registration.py new file mode 100644 index 0000000000..904857213e --- /dev/null +++ b/graphify/extractors/qt_cpp_registration.py @@ -0,0 +1,125 @@ +"""Literal Qt registration facts, retaining unsupported and conditional evidence.""" +from __future__ import annotations + +import json +import re + +from graphify.extractors.qt_cpp_mapping import normalize_type +from graphify.extractors.qt_cpp_syntax import split_arguments, walk +from graphify.extractors.qt_cpp_type_scope import NativeTypeScope + +_SUPPORTED = {"qmlRegisterType", "qmlRegisterUncreatableType", "qmlRegisterSingletonType", + "qmlRegisterSingletonInstance", "qmlRegisterAnonymousType"} +_IDENTIFIER = re.compile(r"[A-Za-z_]\w*") + + +def literal_string(value): + if not re.fullmatch(r'"(?:[^"\\]|\\["\\nrt])*"', value): + return None + try: + return json.loads(value) + except ValueError: + return None + + +def conditional(syntax): + parent = syntax.parent + while parent: + if parent.type in {"if_statement", "switch_statement", "for_statement", "while_statement", + "do_statement", "conditional_expression", "preproc_if", "preproc_ifdef", "preproc_else"}: + return True + parent = parent.parent + return False + + +def conditional_offset(unit, byte): + return any(node.type in {"preproc_if", "preproc_ifdef", "preproc_else"} + and node.start_byte <= byte < node.end_byte for node in walk(unit.tree)) + + +def class_target(unit, syntax, raw_type, classes, *, type_scope=None): + """Qualified lexical candidates only, never a corpus-wide basename lookup.""" + scope = type_scope if type_scope is not None else NativeTypeScope(unit, classes=classes) + return scope.resolve_class(normalize_type(raw_type), syntax.start_byte) + + +def add_macro_registration(mapping, facts, record): + owned = [macro for macro in record["macros"] if mapping.class_at(macro["start_byte"]) is record] + macros = {macro["name"]: macro for macro in owned} + named = macros.get("QML_NAMED_ELEMENT") or macros.get("QML_ELEMENT") or macros.get("QML_ANONYMOUS") + if named is None: + return + name = record["name"] if named["name"] == "QML_ELEMENT" else "" + status, reason = record["status"], "" if record["node_id"] else "class_mapping_unavailable" + if named["name"] == "QML_NAMED_ELEMENT": + args = named["args"] + if len(args) == 1 and _IDENTIFIER.fullmatch(args[0]): + name = args[0] + else: + status, reason = "unsupported", "named_element_requires_identifier" + flags = {"singleton": "QML_SINGLETON" in macros, "anonymous": "QML_ANONYMOUS" in macros, + "creatable": "QML_UNCREATABLE" not in macros and "QML_ANONYMOUS" not in macros} + allowed = {"QML_ELEMENT", "QML_NAMED_ELEMENT", "QML_ANONYMOUS", "QML_SINGLETON", "QML_UNCREATABLE", + "QML_ADDED_IN_VERSION", "QML_REMOVED_IN_VERSION"} + unsupported = sorted(name for name in macros if name.startswith("QML_") and name not in allowed) + if unsupported: + status, reason = "unsupported", "foreign_extended_or_attached_mapping_unsupported" + if conditional(record["syntax"]) or conditional_offset(mapping.unit, named["start_byte"]): + status, reason = "dynamic", "conditional_exposure" + fields = {"class_id": record["node_id"], "generic_target_id": record["node_id"], + "class_name": record["qualified_name"], "mechanism": named["name"], "status": status, + "reason": reason, "module_source_file": mapping.unit.relative_file, "module_required": True, + "unsupported_macros": unsupported, **flags} + fields["annotations"] = [{key: macro[key] for key in ("name", "args", "span")} + for macro in owned if macro["name"].startswith("QML_")] + for macro_name, key in (("QML_ADDED_IN_VERSION", "added_version"), ("QML_REMOVED_IN_VERSION", "removed_version")): + macro = macros.get(macro_name) + if macro: + args = macro["args"] + if len(args) == 2 and all(re.fullmatch(r"\d+", arg) for arg in args): + fields[key] = [int(arg) for arg in args] + else: + fields.update(status="unsupported", reason="exposure_version_expression_unsupported") + facts.add("registration", name, named["span"], owner=record["node_id"] or None, **fields) + + +def add_literal_registrations(mapping, facts, classes, *, alias_nodes=None): + unit = mapping.unit + type_scope = NativeTypeScope(unit, mapping, classes=classes, alias_nodes=alias_nodes) + for syntax in walk(unit.tree): + if syntax.type != "call_expression": + continue + function = syntax.child_by_field_name("function") + name = unit.field(function, "name") if function and function.type == "template_function" else unit.text(function) + if not re.fullmatch(r"qmlRegister[A-Za-z_]\w*", name): + continue + template = function.child_by_field_name("arguments") if function else None + types = split_arguments(unit.source[template.start_byte + 1:template.end_byte - 1]) if template else [] + arguments = syntax.child_by_field_name("arguments") + args = split_arguments(unit.source[arguments.start_byte + 1:arguments.end_byte - 1]) if arguments else [] + owner = mapping.owner_at(syntax.start_byte) + fields = {"mechanism": name, "module_required": False, "status": "unsupported", + "reason": "literal_registration_arguments_unsupported", "class_id": "", "generic_target_id": "", + "raw_type": types[0] if types else "", "class_name": "", "singleton": "Singleton" in name, + "anonymous": name == "qmlRegisterAnonymousType", "creatable": name == "qmlRegisterType"} + raw_name = "" + needed = 2 if fields["anonymous"] else 4 if name == "qmlRegisterType" else 5 + if name not in _SUPPORTED: + fields["reason"] = "registration_mechanism_unsupported" + supported_template = len(types) == 1 or (len(types) == 2 and bool(re.fullmatch(r"\d+", types[1]))) + if name in _SUPPORTED and supported_template and len(args) == needed: + if len(types) == 2: + fields["metaobject_revision"] = int(types[1]) + uri = literal_string(args[0]) + version_args = args[1:2] if fields["anonymous"] else args[1:3] + raw_name = "" if fields["anonymous"] else literal_string(args[3]) + ids, reason = class_target(unit, syntax, types[0], classes, type_scope=type_scope) + if uri and raw_name is not None and all(re.fullmatch(r"\d+", arg) for arg in version_args): + fields.update(uri=uri, major=int(version_args[0]), minor=0 if fields["anonymous"] else int(version_args[1]), + class_id=ids[0] if len(ids) == 1 else "", generic_target_id=ids[0] if len(ids) == 1 else "", + status="resolved" if len(ids) == 1 else "ambiguous" if ids else "unavailable", reason=reason) + records = [record for record in classes if record["node_id"] == fields["class_id"]] + fields["class_name"] = records[0]["qualified_name"] if records else "" + if conditional(syntax): + fields.update(status="dynamic", reason="conditional_registration") + facts.add("registration", raw_name or "", syntax, owner=owner["node_id"] if owner and owner["node_id"] else None, **fields) diff --git a/graphify/extractors/qt_cpp_syntax.py b/graphify/extractors/qt_cpp_syntax.py new file mode 100644 index 0000000000..57239a5022 --- /dev/null +++ b/graphify/extractors/qt_cpp_syntax.py @@ -0,0 +1,234 @@ +"""Bounded C++ source views for Qt overlays; no preprocessing or corpus execution.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re + +from tree_sitter import Language, Node, Parser +import tree_sitter_cpp + +from graphify.extractors.qt_cpp_compat import needs_recovery, recover_cpp_syntax + +MAX_CPP_BYTES = 5_000_000 +_MACRO = re.compile(rb"\b(?:QML_[A-Z_]+|Q_(?:OBJECT|GADGET|PROPERTY|INVOKABLE|SIGNAL|SLOT|SIGNALS|SLOTS|ENUM(?:_NS)?|FLAG(?:_NS)?|REVISION|CLASSINFO|INTERFACES|DECLARE_METATYPE|EMIT)|SIGNAL|SLOT)\b") +_RAW_STRING = re.compile(rb'(?:u8|u|U|L)?R"([^\s()\\]{0,16})\(') +_NUMBER = re.compile(rb"(?:[0-9]|\.[0-9])(?:[A-Za-z0-9_.]|'[A-Za-z0-9_]|(?<=[eEpP])[+-])*") +_MEMBER_CAST = re.compile(rb"\bstatic_cast\s*<[\w:\s*&<>]+\(\s*[\w:]+\s*::\s*\*\s*\)\s*\([\w:\s*&<>,]*\)\s*(?:const\s*)?>\s*\(\s*(&\s*[\w:]+\s*::\s*[A-Za-z_]\w*)\s*\)") + + +class QtCppError(Exception): + """A bounded stage code is safe to publish instead of backend exception text.""" + + def __init__(self, code: str, message: str): + self.code = code + super().__init__(message) + + +def blank(source: bytes) -> bytes: + return re.sub(rb"[^\r\n]", b" ", source) + + +def lexical_code(source: bytes) -> bytes: + """Mask comments/quoted/raw literals in place, including continued comments.""" + result, index = bytearray(source), 0 + while index < len(source): + # C++ preprocessing numbers own digit separators. Skip their complete + # lexical token before testing apostrophes as character-literal starts; + # the parser still validates the unchanged numeric syntax itself. + number = _NUMBER.match(source, index) + if number and (index == 0 or source[index - 1] not in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"): + index = number.end() + continue + start, end = index, index + if source[index:index + 2] == b"//": + end = index + 2 + while end < len(source): + if source[end:end + 1] == b"\n" and source[end - 1:end] != b"\\" and source[end - 2:end] != b"\\\r": + break + end += 1 + elif source[index:index + 2] == b"/*": + close = source.find(b"*/", index + 2) + end = len(source) if close < 0 else close + 2 + else: + raw = _RAW_STRING.match(source, index) + if raw: + close = source.find(b")" + raw[1] + b'"', raw.end()) + end = len(source) if close < 0 else close + len(raw[1]) + 2 + elif source[index:index + 1] in {b'"', b"'"}: + quote, end = source[index], index + 1 + while end < len(source): + if source[end] == 92: + end += 2 + elif source[end] == quote: + end += 1 + break + else: + end += 1 + if end > start: + end = min(end, len(source)) + result[start:end] = blank(source[start:end]) + index = end + else: + index += 1 + return bytes(result) + + +def split_arguments(source: bytes) -> list[str]: + """Split literal call arguments while preserving nested expressions as opaque text.""" + code, depth, start, values = lexical_code(source), 0, 0, [] + for index, char in enumerate(code): + if char in b"([{<": + depth += 1 + elif char in b")]}>" and depth: + depth -= 1 + elif char == 44 and not depth: + values.append(source[start:index].decode().strip()) + start = index + 1 + if source.strip(): + values.append(source[start:].decode().strip()) + return values + + +def source_span(source: bytes, start: int, end: int) -> dict: + result = {"start_byte": start, "end_byte": end} + for side, offset in (("start", start), ("end", end)): + prefix = source[:offset] + result[side + "_row"] = prefix.count(b"\n") + result[side + "_column"] = offset - prefix.rfind(b"\n") - 1 + return result + + +def walk(node): + """Iterative traversal has the same explicit work bounds as parser admission.""" + pending = [(node, 0)] + count = 0 + while pending: + current, depth = pending.pop() + count += 1 + if count > 100_000 or depth > 256: + raise QtCppError("QT_CPP_LIMIT", "Qt C++ AST exceeds size/depth bounds") + yield current + pending.extend((child, depth + 1) for child in reversed(current.named_children)) + + +@dataclass +class CppUnit: + path: Path + relative_file: str + source: bytes + code: bytes + parsed_source: bytes + tree: Node + macros: list[dict] + + @property + def root(self): + return self.tree + + def text(self, node) -> str: + return self.source[node.start_byte:node.end_byte].decode() if node else "" + + def field(self, node, name: str) -> str: + return self.text(node.child_by_field_name(name)) if node else "" + + +def _normalized(source: bytes, code: bytes): + """Known Qt annotation spellings become whitespace, preserving every offset.""" + parsed, macros = bytearray(source), [] + # The bundled grammar rejects valid explicit member-pointer overload casts. + # Keep only the inner pointer in its original byte position for AST recovery; + # native selectors consume the unchanged source/code views and full signature. + for match in _MEMBER_CAST.finditer(code): + start, end = match.span() + parsed[start:end] = blank(source[start:end]) + parsed[match.start(1):match.end(1)] = source[match.start(1):match.end(1)] + for match in _MACRO.finditer(code): + start, end = match.span() + line_start = code.rfind(b"\n", 0, start) + 1 + if code[line_start:start].lstrip().startswith(b"#"): + continue + cursor = end + while cursor < len(code) and code[cursor] in b" \t\r\n": + cursor += 1 + args = [] + if cursor < len(code) and code[cursor] == 40: + depth, argument_start = 1, cursor + 1 + cursor += 1 + while cursor < len(code) and depth: + depth += (code[cursor] == 40) - (code[cursor] == 41) + cursor += 1 + if depth: + raise QtCppError("QT_CPP_SYNTAX", "Qt annotation parentheses are incomplete") + end, args = cursor, split_arguments(source[argument_start:cursor - 1]) + name = match[0].decode() + macros.append({"name": name, "start_byte": start, "end_byte": end, + "args": args, "span": source_span(source, start, end)}) + parsed[start:end] = blank(source[start:end]) + if name in {"Q_SIGNALS", "Q_SLOTS"} and code[end:].lstrip().startswith(b":"): + if name == "Q_SIGNALS" or not re.search(rb"\b(?:public|protected|private)\s*$", code[line_start:start]): + parsed[start:start + 6] = b"public" + elif name in {"SIGNAL", "SLOT"} and args: + parsed[start:start + 1] = b"0" + # Native section keywords and emissions are syntax annotations, not calls. + for match in re.finditer(rb"\b(signals|slots|emit)\b", code): + start, end = match.span() + after = code[end:].lstrip() + if match[0] == b"signals" and after.startswith(b":"): + parsed[start:end] = b"public " + elif match[0] == b"slots" and after.startswith(b":"): + parsed[start:end] = b" " * (end - start) + elif match[0] == b"emit" and re.match(rb"[A-Za-z_]", after): + parsed[start:end] = b" " * (end - start) + return recover_cpp_syntax(bytes(parsed), code, parse=_parse_cpp, walk=walk), macros + + +def _parse_cpp(source: bytes) -> Node: + """Compatibility recovery and final admission share the parser diagnostic.""" + try: + return Parser(Language(tree_sitter_cpp.language())).parse(source).root_node + except Exception as exc: + raise QtCppError("QT_CPP_PARSER", "Cannot parse Qt C++ source") from exc + + +def normalize_qt_cpp(source: bytes) -> bytes | None: + """Generic C++ parsing shares offset-preserving recovery without Qt overlays. + + Invalid annotation recovery remains the overlay's explicit safety failure; + the generic extractor retains its existing malformed-input behavior. + """ + if len(source) > MAX_CPP_BYTES: + return None + code = lexical_code(source) + if not needs_recovery(code) and not _MACRO.search(code) and not re.search(rb"\b(?:signals|slots)\s*:|\bemit\s+[A-Za-z_]|\bQObject\s*::\s*(?:connect|disconnect)\s*\(", code): + return None + try: + normalized, _ = _normalized(source, code) + except QtCppError: + return None + return normalized if normalized != source else None + + +def read_cpp(path: Path, root: Path) -> CppUnit: + """Read one already-admitted source; Qt overlays never discover more files.""" + try: + relative = path.resolve().relative_to(root.resolve()).as_posix() + except ValueError as exc: + raise QtCppError("QT_CPP_ROOT", "Qt C++ source is outside the scan root") from exc + try: + if path.stat().st_size > MAX_CPP_BYTES: + raise QtCppError("QT_CPP_LIMIT", "Qt C++ source exceeds 5 MB") + with path.open("rb") as stream: + source = stream.read(MAX_CPP_BYTES + 1) + source.decode("utf-8") + except (OSError, UnicodeError) as exc: + raise QtCppError("QT_CPP_READ", "Cannot read UTF-8 Qt C++ source") from exc + if len(source) > MAX_CPP_BYTES: + raise QtCppError("QT_CPP_LIMIT", "Qt C++ source exceeds 5 MB") + code = lexical_code(source) + normalized, macros = _normalized(source, code) + tree = _parse_cpp(normalized) + list(walk(tree)) + if tree.has_error: + raise QtCppError("QT_CPP_SYNTAX", "Qt C++ syntax is incomplete or outside the supported profile") + return CppUnit(path, relative, source, code, normalized, tree, macros) diff --git a/graphify/extractors/qt_cpp_type_aliases.py b/graphify/extractors/qt_cpp_type_aliases.py new file mode 100644 index 0000000000..f9b2156e58 --- /dev/null +++ b/graphify/extractors/qt_cpp_type_aliases.py @@ -0,0 +1,181 @@ +"""Accepted header alias shadows; no include execution or corpus discovery. + +Facts retain original declarations and literal includes. Included aliases block +outer spelling fallback; this module never pretends to preprocess their targets. +""" +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path, PurePosixPath +import posixpath +import re + +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.cpp_class_proof import class_fact +from graphify.extractors.cpp_constructors import _decoded +from graphify.extractors.qt_cpp_syntax import MAX_CPP_BYTES, QtCppError, lexical_code, walk +from graphify.qml_resolution_types import source_path + +_IDENTIFIER = re.compile(r"[A-Za-z_]\w*") +_QUALIFIED = re.compile(r"[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*") +_DEPENDENCY = re.compile(rb"\b(?:using\s+(?:[A-Za-z_]\w*\s*=|(?:::)?[A-Za-z_]\w*::)|typedef\b|namespace\s+[A-Za-z_]\w*\s*=)") +_HEADERS = {".h", ".hpp", ".hh", ".hxx"} + + +def type_dependency_source(path): + """Inspect only an explicitly admitted header, bounded like the native reader.""" + if Path(path).suffix.lower() not in _HEADERS: + return False + try: + with Path(path).open("rb") as stream: + source = stream.read(MAX_CPP_BYTES + 1) + except OSError as exc: + raise QtCppError("QT_CPP_READ", "Cannot read admitted header dependency") from exc + # Comments must not admit an otherwise unrelated header. Include literals + # are retained for the admission hint only; parsed facts provide authority. + code = lexical_code(source) + includes = re.finditer(rb"#\s*include", code) + return bool(_DEPENDENCY.search(code) or any(re.match(rb'\s*"', source[match.end():]) for match in includes)) + + +def _include(unit, syntax): + literal = unit.field(syntax, "path") + if not (literal.startswith('"') and literal.endswith('"')): + return "" + value = literal[1:-1] + return value if value and len(value.encode()) <= 384 and "\\" not in value and not PurePosixPath(value).is_absolute() else "" + + +def add_type_dependency_facts(mapping, facts): + """One source owns standalone namespace/class aliases and literal includes.""" + from graphify.extractors.qt_cpp_type_scope import NativeTypeScope + index = NativeTypeScope(mapping.unit, mapping) + for (scopes, name), records in index.bindings.items(): + if any(scope[0] == "block" for scope in scopes): + continue + scope = scopes[-1] if scopes else ("global", "") + for syntax, target, kind, conditional in records: + if kind not in {"alias_declaration", "type_definition", "using_declaration", "namespace_alias_definition"}: + continue + facts.add("type_alias", name, syntax, scope_name=scope[1], scope_kind=scope[0], + binding_kind=kind, target_spelling=target, conditional=conditional, + status="source", reason="included_alias_target_unverified") + for syntax in walk(mapping.unit.tree): + if syntax.type == "preproc_include" and (literal := _include(mapping.unit, syntax)): + facts.add("type_include", literal, syntax, value=literal, status="source") + + +def _accepted(node): + md = qt_metadata(node) + if md.get("kind") not in {"type_alias", "type_include"}: + return None + file, span = node.get("source_file"), md.get("span", {}) + valid_file = (isinstance(file, str) and file and "\\" not in file and ":" not in file + and not PurePosixPath(file).is_absolute() and ".." not in PurePosixPath(file).parts) + numeric = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column") + valid_span = (isinstance(span, dict) and all(type(span.get(key)) is int and span[key] >= 0 for key in numeric) + and span["start_byte"] < span["end_byte"] and span["start_row"] <= span["end_row"] + and (span["start_row"] != span["end_row"] or span["start_column"] < span["end_column"])) + if not valid_file or not valid_span or node.get("_origin") != "ast" or node.get("file_type") != "code": + raise ValueError("QT_METADATA: invalid type dependency provenance") + if node.get("source_location") != f'L{span["start_row"] + 1}-L{span["end_row"] + 1}': + raise ValueError("QT_METADATA: invalid type dependency location") + if md["kind"] == "type_alias" and node.get("label") != f'Qt type_alias: {md.get("raw_name", "")}': + raise ValueError("QT_METADATA: invalid type dependency name") + if md["kind"] == "type_alias": + scope, name = md.get("scope_name"), md.get("raw_name") + if (not isinstance(scope, str) or (scope and not _QUALIFIED.fullmatch(scope)) + or not isinstance(name, str) or not _IDENTIFIER.fullmatch(name) + or md.get("scope_kind") not in {"global", "namespace", "class"} + or (md["scope_kind"] == "global") != (scope == "")): + raise ValueError("QT_METADATA: invalid type alias scope") + elif md.get("value") != md.get("raw_name") or not isinstance(md.get("value"), str): + raise ValueError("QT_METADATA: invalid type include literal") + return file, md + + +def _class_shadow(node, unit): + """Generic source declarations can block SDK fallback without defining it. + + Complete-body/Qt provider admission remains separate. A forward or ambiguous + class spelling must still shadow an external SDK name in an included header. + No extra header is admitted, opened, reparsed or executed by this lookup. + """ + metadata = node.get("metadata") + if not isinstance(metadata, dict) or "cpp_class" not in metadata: + return None + fact = class_fact(node) + # Direct extraction may use absolute in-root paths and has no origin stamp; + # the aggregate supplies portable paths and explicit AST origin. Normalize + # only already-accepted provenance against this unit's verified scan root. + root = unit.path.resolve().parents[len(PurePosixPath(unit.relative_file).parts) - 1] + file = source_path(node, root) + if (not fact or not file or node.get("_origin") not in {None, "ast"} + or node.get("file_type") != "code"): + raise ValueError("QT_METADATA: invalid included native class provenance") + qualified = _decoded(fact["qualified_name_b64"]) + scope, _, name = qualified.rpartition("::") + return file, (scope, name) + + +class IncludedAliasShadows: + """Immutable accepted dependency snapshot, with bounded include graph walks.""" + + def __init__(self, unit, nodes): + self.unit = unit + self.aliases, self.includes = defaultdict(set), defaultdict(set) + self.classes = defaultdict(set) + paths = set() + for node in nodes: + shadow = _class_shadow(node, unit) + if shadow is not None: + file, spelling = shadow + paths.add(file) + self.classes[file].add(spelling) + record = _accepted(node) + if record is None: + continue + file, md = record + paths.add(file) + if md["kind"] == "type_alias": + self.aliases[file].add((md["scope_name"], md["raw_name"])) + else: + self.includes[file].add(md["value"]) + self.paths = frozenset(paths) + self.direct = tuple((syntax.end_byte, _include(unit, syntax)) for syntax in walk(unit.tree) + if syntax.type == "preproc_include" and _include(unit, syntax)) + + def _target(self, owner, literal): + # Quoted includes search the source directory before the explicit scan + # root. Only accepted fact paths participate; no header is opened here. + for base in (posixpath.dirname(owner), ""): + target = posixpath.normpath(posixpath.join(base, literal)) + if target in self.paths and not target.startswith("../"): + return target + return "" + + def _contains(self, inventory, scope, name, byte): + key = scope[-1][1] if scope and scope[-1][0] != "block" else "" if not scope else None + if key is None: + return False + pending = [self._target(self.unit.relative_file, literal) for end, literal in self.direct if end <= byte] + seen = set() + while pending: + file = pending.pop() + if not file or file in seen: + continue + seen.add(file) + if len(seen) > 128: + raise QtCppError("QT_CPP_LIMIT", "Type dependency walk exceeds 128 accepted headers") + if (key, name) in inventory[file]: + return True + pending.extend(self._target(file, literal) for literal in self.includes[file]) + return False + + def blocks(self, scope, name, byte): + """Imported aliases retain no compiler-expanded native target authority.""" + return self._contains(self.aliases, scope, name, byte) + + def class_declared(self, scope, name, byte): + """A source class is a shadow, independently of whether its body maps.""" + return self._contains(self.classes, scope, name, byte) diff --git a/graphify/extractors/qt_cpp_type_scope.py b/graphify/extractors/qt_cpp_type_scope.py new file mode 100644 index 0000000000..e061ada77b --- /dev/null +++ b/graphify/extractors/qt_cpp_type_scope.py @@ -0,0 +1,213 @@ +"""Source-local native type authority without preprocessing or compiler emulation. + +Lexical bindings own their declaration scope and source order. A shadowing alias +that cannot be proved blocks outer lookup; it never falls back to an unrelated +global class. Accepted class identities remain owned by the canonical producer. +""" +from __future__ import annotations + +from dataclasses import dataclass +import re + +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_syntax import walk +from graphify.extractors.qt_cpp_type_aliases import IncludedAliasShadows + +_NAME = re.compile(r"(?:::)?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*") +_CONDITIONAL = {"if_statement", "switch_statement", "for_statement", "while_statement", + "do_statement", "conditional_expression", "preproc_if", "preproc_ifdef", "preproc_else"} + + +@dataclass(frozen=True) +class TypeLookup: + name: str = "" + reason: str = "" + source_declared: bool = False + + +class NativeTypeScope: + """Immutable per-unit lexical index; external spellings are not class proof.""" + + def __init__(self, unit, mapping=None, *, classes=(), alias_nodes=None): + self.unit, self.mapping = unit, mapping + self.syntax = tuple(walk(unit.tree)) + self.bindings = {} + self.classes = {} + self.included = IncludedAliasShadows(unit, alias_nodes if alias_nodes is not None + else mapping.nodes if mapping is not None else ()) + records = list(classes) + (list(mapping.classes) if mapping is not None else []) + if mapping is not None: + records += [{"qualified_name": md.get("class_name"), "node_id": md.get("class_id"), + "is_definition": md.get("is_definition")} + for node in mapping.nodes if (md := qt_metadata(node)).get("kind") == "class"] + for record in records: + name, target = record.get("qualified_name"), record.get("node_id") + if name and target and record.get("is_definition") is True: + self.classes.setdefault(name, set()).add(target) + self.namespaces = {name.rsplit("::", 1)[0] for name in self.classes if "::" in name} + self.namespaces |= {prefix for name in tuple(self.namespaces) + for prefix in self._prefixes(name)} + # Alias/type bindings are independent of generic graph nodes. Missing + # canonical identity may reject a target but cannot erase a shadow. + for node in self.syntax: + kind, name, target = node.type, "", "" + if kind == "alias_declaration": + name, target = unit.field(node, "name"), unit.field(node, "type") + elif kind == "type_definition": + # Each declarator introduces a separate shadow, including + # unsupported pointer/function aliases and comma-separated names. + owner = mapping.owner_at(node.start_byte) if mapping is not None else None + scopes = self._scopes(node.parent, owner) + for declarator in node.children_by_field_name("declarator"): + leaf = declarator + while leaf and leaf.type not in {"type_identifier", "identifier"}: + leaf = leaf.child_by_field_name("declarator") or next(iter(leaf.named_children), None) + name = unit.text(leaf) if leaf else "" + target = unit.field(node, "type") if leaf == declarator else "" + if re.fullmatch(r"[A-Za-z_]\w*", name): + self.bindings.setdefault((scopes, name), []).append((node, target, kind, self._conditional(node))) + continue + elif kind == "using_declaration": + literal = unit.text(node).removeprefix("using").strip().removesuffix(";").strip() + if not literal.startswith("namespace ") and _NAME.fullmatch(literal): + name, target = literal.rsplit("::", 1)[-1], literal + elif kind in {"class_specifier", "struct_specifier"}: + name = unit.field(node, "name") + target = self._qualified(self._scopes(node.parent), name) + elif kind == "namespace_definition": + name = unit.field(node, "name") + if name and "::" not in name: + target = self._qualified(self._scopes(node.parent), name) + self.namespaces.add(target) + elif kind == "namespace_alias_definition": + name = unit.field(node, "name") + target = unit.text(node).split("=", 1)[-1].strip().removesuffix(";").strip() + if not name or not re.fullmatch(r"[A-Za-z_]\w*", name): + continue + owner = mapping.owner_at(node.start_byte) if mapping is not None else None + scopes = self._scopes(node.parent, owner) + record = (node, target, kind, self._conditional(node)) + self.bindings.setdefault((scopes, name), []).append(record) + + @staticmethod + def _prefixes(name): + parts = name.split("::") + return ["::".join(parts[:end]) for end in range(1, len(parts) + 1)] + + def _scopes(self, node, owner=None): + ancestors = [] + while node is not None: + ancestors.append(node) + node = node.parent + scopes, qualified = [], "" + for ancestor in reversed(ancestors): + if ancestor.type in {"namespace_definition", "class_specifier", "struct_specifier"}: + name = self.unit.field(ancestor, "name") + if name: + qualified = "::".join(filter(None, (qualified, name))) + scopes.append(("class" if ancestor.type != "namespace_definition" else "namespace", qualified)) + elif ancestor.type == "compound_statement": + # Out-of-line bodies retain their accepted class scope, while + # block keys isolate disjoint local aliases with the same name. + if owner and owner.get("class_name") and not any(key[0] == "class" for key in scopes): + scopes.append(("class", owner["class_name"])) + scopes.append(("block", ancestor.start_byte, ancestor.end_byte)) + return tuple(scopes) + + @staticmethod + def _qualified(scopes, name): + prefix = next((scope[1] for scope in reversed(scopes) if scope[0] != "block"), "") + return "::".join(filter(None, (prefix, name))) + + @staticmethod + def _conditional(node): + node = node.parent + while node is not None: + if node.type in _CONDITIONAL: + return True + node = node.parent + return False + + def _at(self, byte, owner): + enclosing = [node for node in self.syntax if node.start_byte <= byte < node.end_byte] + node = min(enclosing, key=lambda item: item.end_byte - item.start_byte) if enclosing else self.unit.tree + return self._scopes(node, owner) + + def lookup(self, raw_type, byte, *, owner=None): + """Resolve a literal type spelling, rejecting uncertainty before fallback.""" + if not isinstance(raw_type, str) or len(raw_type.encode()) > 384 or not _NAME.fullmatch(raw_type): + return TypeLookup(reason="native_type_expression_unsupported") + if owner is None and self.mapping is not None: + owner = self.mapping.owner_at(byte) + return self._lookup(raw_type, byte, self._at(byte, owner), frozenset()) + + def _lookup(self, name, byte, scopes, seen): + absolute, name = name.startswith("::"), name.removeprefix("::") + head, separator, tail = name.partition("::") + choices = [()] if absolute else [scopes[:end] for end in range(len(scopes), -1, -1)] + for scope in choices: + if self.included.blocks(scope, head, byte): + return TypeLookup(reason="native_type_included_alias_unavailable") + records = [record for record in self.bindings.get((scope, head), ()) if record[0].start_byte <= byte] + if records: + direct = {target for _, target, kind, _ in records + if kind in {"class_specifier", "struct_specifier", "namespace_definition"}} + if len(records) != 1 and not (len(direct) == 1 and all( + kind in {"class_specifier", "struct_specifier", "namespace_definition"} + for _, _, kind, _ in records)): + return TypeLookup(reason="native_type_binding_ambiguous") + node, target, kind, condition = records[0] + if condition: + return TypeLookup(reason="native_type_binding_conditional") + if kind in {"class_specifier", "struct_specifier", "namespace_definition"}: + result = TypeLookup(target, source_declared=True) + else: + identity = (node.start_byte, node.end_byte) + if identity in seen or len(seen) >= 32: + return TypeLookup(reason="native_type_alias_cycle_or_limit") + if not _NAME.fullmatch(target): + return TypeLookup(reason="native_type_alias_unsupported") + result = self._lookup(target, node.start_byte - 1, scope, seen | {identity}) + return TypeLookup(result.name + separator + tail, result.reason, result.source_declared) if result.name else result + if scope and scope[-1][0] == "class" and self.bindings.get((scope, head)): + return TypeLookup(reason="native_type_declaration_not_visible") + candidate = self._qualified(scope, head) + # Included source declarations block SDK fallback even without a + # complete mapped body. Native target lookup still requires the + # existing accepted complete-class inventory; this adds no provider. + if self.included.class_declared(scope, head, byte): + return TypeLookup(candidate + separator + tail, source_declared=True) + if (not scope or scope[-1][0] != "block") and (candidate in self.classes or candidate in self.namespaces): + return TypeLookup(candidate + separator + tail, source_declared=True) + # A later local alias cannot authorize an earlier use. Retaining an + # unshadowed external spelling preserves SDK input for its own guards. + if any(self.bindings.get((scope, head)) for scope in choices): + return TypeLookup(reason="native_type_declaration_not_visible") + return TypeLookup(name) + + def resolve(self, raw_type, byte, *, owner=None): + return self.lookup(raw_type, byte, owner=owner).name + + def sdk_type(self, raw_type, byte, expected): + """Authorize an external SDK type, never a same-spelled source declaration. + + Source-body identity is deliberately unnecessary for rejection: a forward + declaration or unmapped class still shadows the SDK spelling. Absolute + lookup bypasses local aliases but retains global source shadowing. + """ + result = self.lookup(raw_type, byte) + return result.name == expected and not result.reason and not result.source_declared + + def callable_shadow(self, name, byte, *, absolute=False): + """Visible source callables cannot inherit SDK type-conversion semantics.""" + if self.mapping is None: + return False + scopes = self._at(byte, self.mapping.owner_at(byte)) + names = {name} if absolute else {name} | { + scope[1] + "::" + name for scope in scopes if scope[0] != "block"} + return any(record.get("qualified_name") in names for record in self.mapping.functions) + + def resolve_class(self, raw_type, byte, *, owner=None): + result = self.lookup(raw_type, byte, owner=owner) + ids = sorted(self.classes.get(result.name, ())) if result.name else [] + return ids, (result.reason or ("" if len(ids) == 1 else "class_mapping_ambiguous_or_unavailable" if ids else "class_not_in_corpus")) diff --git a/graphify/extractors/qt_cpp_variables.py b/graphify/extractors/qt_cpp_variables.py new file mode 100644 index 0000000000..8c44363040 --- /dev/null +++ b/graphify/extractors/qt_cpp_variables.py @@ -0,0 +1,82 @@ +"""Bounded source-local C++ type evidence; no arbitrary pointer/value dataflow.""" +from __future__ import annotations + +import re + +from graphify.extractors.qt_cpp_syntax import walk +from graphify.extractors.qt_cpp_type_scope import NativeTypeScope + + +def simple_reference(value): + value = value.strip() + if value.startswith("&"): + value = value[1:].strip() + return value if re.fullmatch(r"this|[A-Za-z_]\w*", value) else "" + + +def type_name(value): + return re.sub(r"\b(?:const|volatile|struct|class)\b|[&*]", "", value).strip() + + +def _name(unit, syntax): + if syntax is None: + return "" + while syntax.type in {"pointer_declarator", "reference_declarator", "init_declarator"}: + syntax = syntax.child_by_field_name("declarator") or next(iter(syntax.named_children), None) + if syntax is None: + return "" + return unit.text(syntax) if syntax.type == "identifier" else "" + + +def variables_at(unit, mapping, position, *, type_scope=None): + """Parameters/local explicit types are authoritative only within their owner.""" + owner = mapping.owner_at(position) + if not owner: + return {} + types = type_scope if type_scope is not None else NativeTypeScope(unit, mapping) + # Parameter types belong to their declaration, before any body-local alias. + variables = {parameter["name"]: types.resolve(type_name(parameter["type"]), owner["span"]["start_byte"], owner=owner) + for parameter in owner.get("parameters", []) if parameter.get("name")} + enclosing = mapping.class_at(position) + # A source-proven constructor callable may still lack an authorized class + # join; its spelling alone cannot give `this` a definite native type. + variables["this"] = "" if owner.get("native_owner_unavailable") else ( + enclosing.get("qualified_name") if enclosing else owner.get("class_name")) or "" + body = owner.get("body") + if body is None: + return variables + for syntax in walk(body): + if syntax.start_byte >= position: + continue + ancestor, visible = syntax.parent, True + while ancestor and ancestor != body: + if ancestor.type in {"compound_statement", "lambda_expression", "for_statement", "for_range_loop", "catch_clause"}: + if not ancestor.start_byte <= position < ancestor.end_byte: + visible = False + break + ancestor = ancestor.parent + if not visible: + continue + if syntax.type in {"parameter_declaration", "optional_parameter_declaration"}: + parameter = _name(unit, syntax.child_by_field_name("declarator")) + if parameter: + variables[parameter] = types.resolve(type_name(unit.field(syntax, "type")), syntax.start_byte, owner=owner) + if syntax.type == "declaration": + name, value = _name(unit, syntax.child_by_field_name("declarator")), unit.field(syntax, "type") + if not name: + continue + declared = type_name(value) + if declared == "auto": + declarator = syntax.child_by_field_name("declarator") + initial = unit.field(declarator, "value") if declarator else "" + match = re.fullmatch(r"new\s+([A-Za-z_]\w*(?:::\w+)*)\s*(?:\([^;]*\)|\{[^;]*\})?", initial) + declared = match[1] if match else "" + variables[name] = types.resolve(declared, syntax.start_byte, owner=owner) if declared else "" + elif syntax.type in {"assignment_expression", "update_expression"}: + left = syntax.child_by_field_name("left") or syntax.child_by_field_name("argument") + name = unit.text(left) if left else "" + if name in variables: + # Even an explicitly typed pointer may now refer to another + # runtime instance; retain no handle identity across reassignment. + variables[name] = "" + return variables diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index a0b7eff7d6..ea5efe0ac2 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2942,6 +2942,7 @@ def _source_stem(node: dict) -> str: def _merge_decl_def_classes( all_nodes: list[dict], all_edges: list[dict], + all_raw_calls=(), ) -> None: """Merge a class (and its methods) declared in a header with its definition in a sibling impl file into ONE node, for C/C++/ObjC (#1547, #1556). @@ -2989,6 +2990,13 @@ def _merge_decl_def_classes( member ``_make_id(class_id, "bar")``. So every decl/def member pair is itself an id-collision across the same sibling file set and collapses by the same rule. """ + # AST constructor qualification, complete bodies and matching prototypes own + # this join; a header collision alone cannot prove a constructor declaration. + from graphify.extractors.cpp_constructors import bind_cpp_constructors, constructor_merge_allowed + from graphify.extractors.cpp_class_proof import class_body_keeper, class_merge_allowed + from graphify.extractors.cpp_member_identity import canonicalize_cpp_members, member_merge_allowed + canonicalize_cpp_members(all_nodes, all_edges, all_raw_calls) + bind_cpp_constructors(all_nodes, all_edges) # Group every code node by id, recording the distinct source files involved. by_id: dict[str, list[dict]] = {} for n in all_nodes: @@ -3006,6 +3014,8 @@ def _merge_decl_def_classes( for nid, group in by_id.items(): if len(group) < 2: continue + if not constructor_merge_allowed(group) or not class_merge_allowed(group) or not member_merge_allowed(group): + continue # The distinct source files of this collision must form a clean sibling # header/impl set with exactly one header. Each file must parse as a # header/impl file (others -> bail), share one directory + base stem. @@ -3039,6 +3049,9 @@ def _merge_decl_def_classes( if len(base_headers) > 1: continue keeper = base_headers[0] if base_headers else min(headers, key=_source_stem) + # Class identity stays unchanged, but an earlier forward header cannot + # replace the only complete body's authoritative source file and span. + keeper = class_body_keeper(group, keeper) # The keeper is the DECLARATION, so without this the graph reports the # header as the symbol's only location and the definition site — the file # and line a reader actually wants — is discarded with the dropped node. diff --git a/graphify/graph_direction.py b/graphify/graph_direction.py new file mode 100644 index 0000000000..849234898c --- /dev/null +++ b/graphify/graph_direction.py @@ -0,0 +1,29 @@ +"""Accepted edge-pair orientation shared by dependency and source-owner consumers. + +Markers may reverse undirected storage, but cannot supply foreign endpoints or +partly replace raw orientation. Unmarked legacy edges keep their original pair. +""" +from __future__ import annotations + + +def logical_endpoints(source, target, data, *, directed): + """Return the accepted logical pair, or None for inconsistent direction transport.""" + if "_src" not in data and "_tgt" not in data: + return source, target + if "_src" not in data or "_tgt" not in data: + return None + src, tgt = data["_src"], data["_tgt"] + # Exact endpoint types prevent bool/integer coercion from authorizing a + # malformed serialized ID. Equality alone is insufficient for that case. + if type(src) is type(source) and type(tgt) is type(target) and src == source and tgt == target: + return src, tgt + if (not directed and type(src) is type(target) and type(tgt) is type(source) + and src == target and tgt == source): + return src, tgt + return None + + +def valid_direction_pairs(edges, *, directed): + """Preflight explicit edge tuples before any comparison or publication shortcut.""" + return all(logical_endpoints(source, target, data, directed=directed) is not None + for source, target, data in edges) diff --git a/graphify/path_provenance.py b/graphify/path_provenance.py new file mode 100644 index 0000000000..4f2b64084b --- /dev/null +++ b/graphify/path_provenance.py @@ -0,0 +1,37 @@ +"""Readable, bounded source evidence shared by CLI and MCP path rendering.""" +from __future__ import annotations + +from graphify.security import sanitize_label + + +def path_provenance(nodes, edges) -> str: + """Render actual declaration and relationship sources without metadata dumps. + + Path traversal owns ordering and direction. Missing locations stay absent; + this view never substitutes a target declaration for an edge's call site. + """ + lines = [] + for data in nodes: + if source := _source(data): + label = sanitize_label(str(data.get("label") or "node")) + lines.append(f" Node {label}: {source}") + for data in edges: + if source := _source(data): + relation = sanitize_label(str(data.get("relation") or "related")) + confidence = sanitize_label(str(data.get("confidence") or "")) + context = sanitize_label(str(data.get("context") or "")) + suffix = f" [{confidence}]" if confidence else "" + if context: + suffix += f" ({context})" + lines.append(f" Edge {relation}{suffix}: {source}") + return "\nSource evidence:\n" + "\n".join(dict.fromkeys(lines)) if lines else "" + + +def _source(data): + path = data.get("source_file") + if not isinstance(path, str) or not path: + return "" + path = sanitize_label(path[:512]) + location = data.get("source_location") + location = sanitize_label(location[:128]) if isinstance(location, str) else "" + return f"{path}:{location}" if location else path diff --git a/graphify/paths.py b/graphify/paths.py index 8cfca2e65d..97fc20ba08 100644 --- a/graphify/paths.py +++ b/graphify/paths.py @@ -26,6 +26,20 @@ GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out") +def _unlink_replace_file(path: "str | Path") -> None: + """Consume an owned replacement file, clearing only its Windows read-only bit.""" + try: + os.unlink(path) + except PermissionError: + mode = os.stat(path, follow_symlinks=False).st_mode + if os.name != "nt" or not stat.S_ISREG(mode) or mode & stat.S_IWRITE: + raise + # Copies preserve the source mode. Windows cannot unlink that owned + # read-only copy/source; never follow a link or alter its target here. + os.chmod(path, mode | stat.S_IWRITE) + os.unlink(path) + + def os_replace_with_fallback(src: "str | Path", dst: "str | Path") -> None: """``os.replace(src, dst)``, falling back to a copy for a known set of Windows quirks (#3508) that raise even when ``src``/``dst`` are the same @@ -47,8 +61,9 @@ def os_replace_with_fallback(src: "str | Path", dst: "str | Path") -> None: or file) aside as a backup rather than deleting it outright, and only then renames the temp copy into ``dst``'s place -- matching replace's "whatever was there is gone, a plain file replaces it" semantics. If that - final rename fails, the backup is renamed straight back so a mid-swap - failure leaves the original in place rather than leaving ``dst`` missing. + final rename or cleanup fails, restore the source and prior destination. + A second OS failure during restoration is an explicit integrity failure; + the recovery backup is retained rather than silently discarded. """ try: os.replace(src, dst) @@ -56,6 +71,7 @@ def os_replace_with_fallback(src: "str | Path", dst: "str | Path") -> None: except OSError as exc: if not isinstance(exc, PermissionError) and getattr(exc, "winerror", None) != 17: raise + replace_error = exc import shutil dst = os.fspath(dst) if os.path.normcase(os.path.abspath(os.fspath(src))) == os.path.normcase(os.path.abspath(dst)): @@ -64,38 +80,62 @@ def os_replace_with_fallback(src: "str | Path", dst: "str | Path") -> None: # the "back up dst" step and then crash unlinking a path that no # longer exists at the end. return + try: + destination_mode = os.stat(dst, follow_symlinks=False).st_mode + except FileNotFoundError: + destination_mode = None + # A fallback must not bypass a real destination rejection. On Windows a + # read-only file can be renamed aside but not removed: doing so previously + # published new bytes and then reported failure during source cleanup. + # lstat semantics keep direct symlink replacement distinct from its target. + if destination_mode is not None and (stat.S_ISDIR(destination_mode) or ( + os.name == "nt" and stat.S_ISREG(destination_mode) + and not destination_mode & stat.S_IWRITE)): + raise replace_error dst_dir = os.path.dirname(dst) or "." fd, tmp_copy = tempfile.mkstemp(dir=dst_dir, prefix=".gfy-replace-", suffix=".tmp") os.close(fd) + backup = None + backup_moved = landed = source_removed = False try: shutil.copy2(src, tmp_copy) - backup = None if os.path.lexists(dst): bfd, backup = tempfile.mkstemp(dir=dst_dir, prefix=".gfy-replace-bak-", suffix=".tmp") os.close(bfd) os.unlink(backup) # reserve the name only; rename needs it free on Windows os.rename(dst, backup) # a plain rename moves a symlink itself, never its target - try: - os.rename(tmp_copy, dst) - except BaseException: - if backup is not None: - try: - os.rename(backup, dst) - except OSError: - pass # best-effort restore; the swap failure below still propagates - raise + backup_moved = True + os.rename(tmp_copy, dst) + landed = True + # Keep the prior destination until both move cleanup operations have + # completed. A cleanup exception must not mean newly published data. + _unlink_replace_file(src) + source_removed = True if backup is not None: - try: - os.unlink(backup) - except OSError: - pass + _unlink_replace_file(backup) except BaseException: + restore_error = None try: - os.unlink(tmp_copy) - except OSError: - pass + if landed: + if source_removed: + if os.path.lexists(src): + raise OSError("Replacement source changed during rollback") + shutil.copy2(dst, src) + os.rename(dst, tmp_copy) + if backup_moved and backup is not None: + os.rename(backup, dst) + except OSError as error: + restore_error = error + try: + if os.path.lexists(tmp_copy): + _unlink_replace_file(tmp_copy) + except OSError as error: + if restore_error is None: + raise OSError("Atomic replacement cleanup failed; destination restored") from error + if restore_error is not None: + recovery = "recovery backup retained" if backup and os.path.lexists(backup) else "manual recovery required" + raise OSError("Atomic replacement rollback failed; " + recovery) from restore_error raise - os.unlink(src) def _atomic_replace(path: "str | Path", write_fn) -> None: @@ -518,6 +558,24 @@ def load_node_link_graph(path_or_data): if isinstance(data, dict) and "links" not in data and "edges" in data: data = dict(data, links=data["edges"]) try: - return json_graph.node_link_graph(data, edges="links") + graph = json_graph.node_link_graph(data, edges="links") except TypeError: # networkx too old for the edges kwarg; default is "links" - return json_graph.node_link_graph(data) + graph = json_graph.node_link_graph(data) + if not graph.is_directed(): + # Export stores the producer's orientation in serialized endpoints. + # NetworkX's undirected iteration order cannot recover that contract. + for link in data.get("links", []): + metadata = link.get("metadata", {}) + qml = metadata.get("qml", {}) if isinstance(metadata, dict) else {} + qt = metadata.get("qt", {}) if isinstance(metadata, dict) else {} + if not any(isinstance(value, dict) and value.get("contract_version") == 1 for value in (qml, qt)): + continue + source, target = link["source"], link["target"] + attributes = (graph.edges[source, target, link.get("key", 0)] + if graph.is_multigraph() else graph.edges[source, target]) + # Only an absent pair may be restored from serialized endpoints. + # Explicit partial/foreign markers remain visible for consumer + # rejection; reload must not turn corrupt transport into proof. + if "_src" not in attributes and "_tgt" not in attributes: + attributes["_src"], attributes["_tgt"] = source, target + return graph diff --git a/graphify/publication.py b/graphify/publication.py new file mode 100644 index 0000000000..9a705901b0 --- /dev/null +++ b/graphify/publication.py @@ -0,0 +1,209 @@ +"""Caller-owned product transactions; AST caches and runtime execution are separate. + +Products are prepared on disk before publication. Ordinary exceptions restore the +prior cohort; process/power loss is not a multi-file atomicity guarantee. Failed +rollback retains recovery copies and reports integrity failure explicitly. +""" +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import stat +import sys +import tempfile + +from graphify.paths import os_replace_with_fallback + + +def _same(left: Path, right: Path) -> bool: + """Compare bounded chunks rather than duplicating large graph payloads in memory.""" + if not left.is_file() or not right.is_file() or left.stat().st_size != right.stat().st_size: + return False + with left.open("rb") as a, right.open("rb") as b: + while True: + chunk, other = a.read(1_048_576), b.read(1_048_576) + if chunk != other: + return False + if not chunk: + return True + + +def _remove_owned(path: Path) -> None: + """Only transaction-owned copies may have their Windows read-only bit cleared.""" + try: + path.unlink(missing_ok=True) + except PermissionError: + if os.name != "nt" or path.is_symlink(): + raise + path.chmod(stat.S_IWRITE) + path.unlink(missing_ok=True) + + +class ProductPublication: + """One bounded output cohort with explicit stage/commit/rollback ownership. + + The caller supplies names and its replace boundary, preserving existing OS + compatibility hooks. Existing serializers write independent stage paths; + stages never establish accepted graph/root/manifest/state on their own. + """ + + def __init__(self, output: Path, names, *, replace=os_replace_with_fallback): + self.output, self.replace = Path(output), replace + self.targets, self.originals, self.stages = {}, {}, {} + self.attempted, self.committed, self.recovery = [], False, False + names = tuple(names) + if len(names) > 32 or any(not isinstance(name, str) for name in names): + raise ValueError("GRAPH_PUBLICATION_FAILED: invalid product inventory") + if len(set(names)) != len(names): + raise ValueError("GRAPH_PUBLICATION_FAILED: duplicate product inventory") + seen = set() + for name in names: + if (not name or len(name) > 128 or any(char in name for char in "/\\:\0") + or Path(name).name != name or name in {".", ".."}): + raise ValueError("GRAPH_PUBLICATION_FAILED: invalid product name") + try: + target = Path(os.path.realpath(self.output / name)) + except Exception as error: + raise OSError("GRAPH_PUBLICATION_FAILED: cannot resolve product destination; prior products retained") from error + identity = os.path.normcase(str(target)) + if identity in seen: + raise ValueError("GRAPH_PUBLICATION_FAILED: products share one destination") + seen.add(identity) + self.targets[name] = target + scratch_created = False + try: + # Setup is part of the publication boundary too. An OS error may + # include private paths, and a partial setup owns only its scratch. + self.output.mkdir(parents=True, exist_ok=True) + self.scratch = Path(tempfile.mkdtemp(dir=self.output, prefix=".gfy-publish-")) + scratch_created = True + self.staging, self.snapshot, self.restore = (self.scratch / part for part in ("stage", "old", "restore")) + for folder in (self.staging, self.snapshot, self.restore): + folder.mkdir() + for name, target in self.targets.items(): + original = self.snapshot / name + if target.exists(): + if not target.is_file(): + raise OSError("GRAPH_PUBLICATION_FAILED: destination is not a file") + shutil.copy2(target, original) + self.originals[name] = original + else: + self.originals[name] = None + except BaseException as error: + if scratch_created: + try: + self._cleanup() + except Exception as cleanup_error: + raise OSError("GRAPH_PUBLICATION_CLEANUP: prior products retained; setup cleanup retry required") from cleanup_error + if isinstance(error, Exception): + raise OSError("GRAPH_PUBLICATION_FAILED: cannot prepare product transaction; prior products retained") from error + raise + + def __enter__(self): + return self + + def stage(self, name: str) -> Path: + """Seed prior bytes for existing serializers without touching accepted products.""" + if name not in self.targets: + raise ValueError("GRAPH_PUBLICATION_FAILED: unknown product") + if name not in self.stages: + staged = self.staging / name + original = self.originals[name] + if original is not None: + shutil.copy2(original, staged) + # Own staging is writable even when its original is not. The + # real destination is checked only if publication changes it. + staged.chmod(staged.stat().st_mode | stat.S_IWRITE) + self.stages[name] = staged + return self.stages[name] + + def commit(self) -> tuple[str, ...]: + """Publish prepared changes, then mark one coherent cohort committed.""" + changed = [] + # Even an unchanged stage must agree with its snapshot: another writer + # cannot silently change one member while this run advances the rest. + for name, target in self.targets.items(): + original = self.originals[name] + if original is not None and not _same(target, original): + raise OSError("GRAPH_PUBLICATION_FAILED: destination changed during preparation") + if original is None and target.exists(): + raise OSError("GRAPH_PUBLICATION_FAILED: destination appeared during preparation") + for name, stage in self.stages.items(): + target, original = self.targets[name], self.originals[name] + if not stage.is_file(): + raise OSError("GRAPH_PUBLICATION_FAILED: staged product is missing") + if original is not None and _same(stage, original): + continue + if original is not None: + mode = target.stat().st_mode + if os.name == "nt" and not mode & stat.S_IWRITE: + raise PermissionError("GRAPH_PUBLICATION_FAILED: read-only " + name) + stage.chmod(stat.S_IMODE(mode)) + changed.append(name) + try: + for name in changed: + self.targets[name].parent.mkdir(parents=True, exist_ok=True) + self.attempted.append(name) + self.replace(self.stages[name], self.targets[name]) + except BaseException: + self._rollback() + raise + self.committed = True + return tuple(changed) + + def _rollback(self) -> None: + """Restore durable originals; a second fault keeps all remaining recovery copies.""" + # Retain recovery evidence until EVERY restoration finishes. Ordinary + # non-OS faults and an interrupted rollback must not trigger cleanup. + self.recovery = True + failed = False + for name in reversed(self.attempted): + target, original = self.targets[name], self.originals[name] + try: + if original is None: + _remove_owned(target) + elif not _same(target, original): + # Copy rather than consume recovery evidence until every + # restored target has completed and can be read back. + restore = self.restore / name + shutil.copy2(original, restore) + os_replace_with_fallback(restore, target) + if not _same(target, original): + raise OSError("Restored product does not match its snapshot") + except Exception: + failed = True + if failed: + raise OSError("GRAPH_PUBLICATION_RECOVERY: rollback incomplete; recovery copies retained") + self.recovery = False + + def _cleanup(self) -> None: + """Remove only known copies; unknown/locked files are retained for diagnosis.""" + for part in ("stage", "old", "restore"): + # Reconstruct owned children so partially completed setup does not + # require every stage attribute to have been assigned already. + folder = self.scratch / part + for name in self.targets: + _remove_owned(folder / name) + if folder.exists(): + folder.rmdir() + self.scratch.rmdir() + + def __exit__(self, kind, error, traceback): + if self.recovery: + return False + try: + self._cleanup() + except Exception: + if self.committed: + # Publication succeeded. Cleanup cannot retroactively report + # a failed graph operation after its cohort became authoritative. + print("warning: GRAPH_PUBLICATION_CLEANUP: committed products retained; cleanup retry required", + file=sys.stderr) + else: + raise OSError("GRAPH_PUBLICATION_CLEANUP: prior products retained; cleanup retry required") from error + if isinstance(error, Exception) and not self.committed: + # Do not expose serializer bodies or filesystem paths in the new + # diagnostic. Chaining retains the precise internal failure cause. + raise OSError("GRAPH_PUBLICATION_FAILED: product preparation or publication failed; prior products retained") from error + return False diff --git a/graphify/qml_module_index.py b/graphify/qml_module_index.py new file mode 100644 index 0000000000..b11c291950 --- /dev/null +++ b/graphify/qml_module_index.py @@ -0,0 +1,225 @@ +"""Per-run module/directory indexes over accepted facts; never widen the corpus.""" + +from __future__ import annotations + +import posixpath +from pathlib import Path + +from graphify.qml_resolution_types import Resolution, answer, qml_metadata, relative_reference, source_path + +MAX_MODULE_RESOLUTION_STEPS = 1024 + + +class QmlModuleIndex: + """Own lookup tables, borrow declarations read-only, and preserve provider identity.""" + + def __init__(self, nodes, edges, *, root: Path, import_roots=None, native_index=None, project_index=None): + self.root = Path(root).resolve() + self.native_index, self.project_index = native_index, project_index + self.import_roots = tuple(import_roots if import_roots is not None else (".",)) + self.nodes = {} + self.paths = {} + self.by_file = {} + self.parents = {} + for node in nodes: + path = source_path(node, self.root) + if path is not None and node.get("id"): + self.nodes[node["id"]] = node + self.paths[node["id"]] = path + self.by_file.setdefault(path, []).append(node["id"]) + for edge in edges: + if edge.get("relation") == "contains": + self.parents.setdefault(edge["target"], set()).add(edge["source"]) + self.components = {} + self.imports = {} + self.providers = {} + self.exports = {} + for nid, node in self.nodes.items(): + md, path = qml_metadata(node), self.paths[nid] + kind = md.get("kind") + if kind == "component": + self.components.setdefault(path, []).append(nid) + elif kind == "import": + self.imports.setdefault(path, []).append(nid) + elif kind == "module" or (kind == "file" and md.get("metadata_format") == "qmldir"): + self.providers.setdefault(posixpath.dirname(path), []).append(nid) + elif kind == "export": + self.exports.setdefault(posixpath.dirname(path), []).append(nid) + # A module declaration supersedes its containing qmldir file as provider. + for directory, ids in self.providers.items(): + modules = [nid for nid in ids if qml_metadata(self.nodes[nid]).get("kind") == "module"] + self.providers[directory] = sorted(modules or ids) + + def md(self, node_or_id): + """Native lookup views are read-only; generic C++ nodes stay unchanged.""" + node = self.nodes[node_or_id] if isinstance(node_or_id, str) else node_or_id + native = self.native_index.metadata(node["id"]) if self.native_index else {} + return native or qml_metadata(node) + + def _target(self, export: str, *, importer: str) -> Resolution: + node, md = self.nodes[export], qml_metadata(self.nodes[export]) + source = self.paths[export] + if md.get("internal") and posixpath.dirname(importer) != posixpath.dirname(source): + return Resolution("unavailable", reason="internal_export", evidence=(export,)) + target = relative_reference(source, str(md.get("target_path") or "")) + if target is None or target not in self.by_file: + return Resolution("unavailable", reason="target_outside_corpus", evidence=(export,)) + candidates = self.components.get(target, []) + if md.get("export_kind") == "script": + candidates = [nid for nid in self.by_file[target] + if self.nodes[nid].get("label") == posixpath.basename(target)] + if md.get("singleton") and candidates: + if not all(qml_metadata(self.nodes[nid]).get("singleton") for nid in candidates): + return Resolution("unavailable", reason="singleton_pragma_missing", evidence=(export,)) + return answer(candidates, reason="component_unavailable", evidence=(export,)) + + def directory_type(self, directory: str, name: str, *, importer: str) -> Resolution: + exports = self.exports.get(directory) + if exports is not None: + matches = [nid for nid in exports if qml_metadata(self.nodes[nid]).get("raw_name") == name and + qml_metadata(self.nodes[nid]).get("export_kind") == "type"] + versions = [(qml_metadata(self.nodes[nid]).get("major"), + qml_metadata(self.nodes[nid]).get("minor")) for nid in matches] + versioned = [version for version in versions if version[0] is not None] + if versioned: + latest = max(versioned) + matches = [nid for nid, version in zip(matches, versions) if version == latest] + results = [self._target(nid, importer=importer) for nid in matches] + return combine(results, reason="type_unavailable") + candidates = [] + for path, ids in self.components.items(): + if posixpath.dirname(path) == directory and posixpath.basename(path) == name + ".qml": + candidates.extend(ids) + return answer(candidates, reason="type_unavailable") + + def module_provider(self, uri: str, major=None, minor=None) -> Resolution: + candidates = [] + for rank, import_root in enumerate(self.import_roots): + prefix = posixpath.normpath(str(import_root).replace("\\", "/")) + if prefix.startswith("/") or prefix == ".." or prefix.startswith("../") or ":" in prefix: + continue + expected = posixpath.normpath(posixpath.join(prefix, uri.replace(".", "/"))) + for directory, ids in self.providers.items(): + suffix = directory[len(expected):] if directory.startswith(expected) else "invalid" + if suffix not in {"", f".{major}", f".{major}.{minor}"}: + continue + for nid in ids: + if qml_metadata(self.nodes[nid]).get("uri") != uri: + continue + # Explicit root order and exact versioned layout are evidence. + priority = (rank, 0 if minor is not None and suffix == f".{major}.{minor}" else + 1 if major is not None and suffix == f".{major}" else 2) + candidates.append((priority, nid)) + if not candidates: + return Resolution("unavailable", reason="module_not_in_import_roots") + priority = min(item[0] for item in candidates) + return answer([nid for key, nid in candidates if key == priority], reason="module_unavailable") + + def module_import(self, uri: str, major=None, minor=None) -> Resolution: + """Require an observed available module version before selecting its types.""" + provider = self.module_provider(uri, major, minor) + if provider.status != "resolved": + return provider + selected, _, _ = self._module_version(provider, major, minor) + return selected + + def _module_version(self, provider: Resolution, major, minor): + if provider.target_id is None: + return provider, major, minor + source = self.paths[provider.target_id] + directory = posixpath.dirname(source) + records = self.exports.get(directory, []) + versions = set() + for nid in records: + md = qml_metadata(self.nodes[nid]) + ma, mi = md.get("major"), md.get("minor") + if isinstance(ma, int) and isinstance(mi, int): + versions.add((ma, mi)) + if major is not None and not versions: + return Resolution("unavailable", reason="module_version_unavailable"), major, minor + if major is None and versions: + major, minor = max(versions) + elif major is not None and minor is None: + minors = [mi for ma, mi in versions if ma == major] + if not minors: + return Resolution("unavailable", reason="module_version_unavailable"), major, minor + minor = max(minors) + if versions and (major, minor) not in versions: + return Resolution("unavailable", reason="module_version_unavailable"), major, minor + return provider, major, minor + + def module_type(self, uri: str, major, minor, name: str, *, importer: str, seen=(), _budget=None, + _export_kind="type") -> Resolution: + # One query owns the work budget across branches, including repeated DAG paths. + budget = [MAX_MODULE_RESOLUTION_STEPS] if _budget is None else _budget + budget[0] -= 1 + if budget[0] < 0: + return Resolution("unsupported", reason="module_resolution_work_limit") + key = (uri, major, minor) + if key in seen or len(seen) >= 32: + return Resolution("unsupported", reason="module_import_cycle_or_limit") + provider = QmlModuleIndex.module_import(self, uri, major, minor) + if provider.target_id is None: + return provider + _, major, minor = self._module_version(provider, major, minor) + source = self.paths[provider.target_id] + directory = posixpath.dirname(source) + records = self.exports.get(directory, []) + related = [self.nodes[nid] for nid in self.by_file[source]] + if any(qml_metadata(node).get("kind") == "prefer" for node in related): + return Resolution("unsupported", reason="preferred_path_requires_resource_index") + matches = [] + for nid in records: + md = qml_metadata(self.nodes[nid]) + version = (md.get("major"), md.get("minor")) + if md.get("export_kind") == _export_kind and md.get("raw_name") == name and (version[0] is None or + (version[0] == major and isinstance(version[1], int) and + isinstance(minor, int) and version[1] <= minor)): + matches.append(((-1, -1) if version[0] is None else version, nid)) + if matches: + best = max(version for version, _ in matches) + result = combine([self._target(nid, importer=importer) for version, nid in matches if version == best]) + return Resolution(result.status, result.target_id, result.reason, + tuple(sorted(set(result.evidence + (provider.target_id,)))), result.candidates) + imports = [qml_metadata(node) for node in related if qml_metadata(node).get("kind") == "module_import"] + return combine([self.module_type(md["uri"], major if md.get("auto") else md.get("major"), + minor if md.get("auto") else md.get("minor"), name, + importer=importer, seen=seen + (key,), _budget=budget, + _export_kind=_export_kind) for md in imports]) + + def module_script(self, uri: str, major, minor, name: str, *, importer: str) -> Resolution: + """Script namespace exports have a separate lookup role from object types.""" + return self.module_type(uri, major, minor, name, importer=importer, _export_kind="script") + + def directory_script(self, directory: str | None, name: str, *, importer: str) -> Resolution: + """Only observed qmldir script exports enter a directory import namespace.""" + matches = [nid for nid in self.exports.get(directory, []) + if qml_metadata(self.nodes[nid]).get("export_kind") == "script" + and qml_metadata(self.nodes[nid]).get("raw_name") == name] + versioned = [(qml_metadata(self.nodes[nid]).get("major"), + qml_metadata(self.nodes[nid]).get("minor")) for nid in matches + if qml_metadata(self.nodes[nid]).get("major") is not None] + if versioned: + latest = max(versioned) + matches = [nid for nid in matches if (qml_metadata(self.nodes[nid]).get("major"), + qml_metadata(self.nodes[nid]).get("minor")) == latest] + return combine([self._target(nid, importer=importer) for nid in matches], reason="script_namespace_unavailable") + + +def combine(results, *, reason="type_unavailable") -> Resolution: + """Preserve ambiguity even when a competing result also has a unique endpoint.""" + results = list(results) + blocked = [result for result in results if result.status == "unsupported"] + if blocked: + return blocked[0] + ids, evidence = [], [] + for result in results: + if result.target_id: + ids.append(result.target_id) + ids.extend(result.candidates) + evidence.extend(result.evidence) + if ids: + return answer(ids, reason=reason, evidence=evidence) + if results: + return results[0] + return Resolution("unavailable", reason=reason) diff --git a/graphify/qml_projection.py b/graphify/qml_projection.py new file mode 100644 index 0000000000..b0a82ececb --- /dev/null +++ b/graphify/qml_projection.py @@ -0,0 +1,33 @@ +"""Narrow source-backed exceptions to generic cross-language phantom guards.""" +from pathlib import Path +from graphify.extractors.qml_facts import qml_metadata + + +def allows_qml_script_edge(source: dict, target: dict, edge: dict, *, target_id: str | None = None) -> bool: + """Only dedicated QML sites can bridge to an accepted JavaScript resource. + + A context string or matching label alone is insufficient. The ordinary + generic JS graph remains independent from the QML script overlay. + """ + source_qml = source.get("metadata", {}).get("qml", {}) + target_qml = target.get("metadata", {}).get("qml", {}) + if source_qml.get("contract_version") != 1 or edge.get("confidence") != "INFERRED": + return False + if Path(source.get("source_file") or "").suffix.lower() not in {".qml", ".js", ".mjs"}: + return False + if Path(target.get("source_file") or "").suffix.lower() not in {".js", ".mjs"}: + return False + if edge.get("relation") == "imports": + try: + proof = qml_metadata(edge) + except ValueError: + return False + accepted_file = (proof.get("target_role") == "script_file" + and proof.get("target_file_id") == (target_id or target.get("id")) + and proof.get("target_file") == target.get("source_file")) + return (source_qml.get("kind") == "import_resolution" and accepted_file + and edge.get("context") == "qml_import_resolution") + if edge.get("relation") == "calls": + return (source_qml.get("kind") == "call" and target_qml.get("kind") == "qml_script_function" + and target_qml.get("contract_version") == 1 and edge.get("context") == "qml_script_call") + return False diff --git a/graphify/qml_relationship_lookup.py b/graphify/qml_relationship_lookup.py new file mode 100644 index 0000000000..0ce80875ad --- /dev/null +++ b/graphify/qml_relationship_lookup.py @@ -0,0 +1,148 @@ +"""Bounded alias and imported-script lookup over accepted source facts only.""" +from __future__ import annotations + +from graphify.qml_resolution_types import Resolution, answer, qml_metadata, relative_reference + + +class RelationshipLookup: + """No global labels, value evaluation, runtime IDs or borrowed-node mutation.""" + + def __init__(self, index): + self.index = index + self.aliases, self.functions, self.exports, self.script_imports = {}, {}, {}, {} + for nid, node in index.nodes.items(): + md, path = qml_metadata(node), index.paths[nid] + if md.get("kind") == "alias": + self.aliases.setdefault(md.get("owner_id"), []).append(nid) + elif md.get("kind") == "qml_script_function": + self.functions.setdefault((path, md.get("raw_name")), []).append(nid) + for name in md.get("export_names", []): + self.exports.setdefault((path, name), []).append(nid) + elif md.get("kind") == "script_import": + self.script_imports.setdefault((path, md.get("qualifier")), []).append(nid) + + def _script_function(self, file, name, *, external): + table = self.exports if external else self.functions + candidates = [nid for nid in table.get((file, name), []) + if qml_metadata(self.index.nodes[nid]).get("static_callable") is not False] + return answer(candidates, reason="script_member_not_exported_or_missing") + + def _script(self, file, reference): + parts = reference.split(".") + if len(parts) == 1: + local = self._script_function(file, reference, external=False) + if local.status in {"resolved", "ambiguous"}: + return local + imports = self.script_imports.get((file, parts[0]), []) + answers = [] + for nid in imports: + md = qml_metadata(self.index.nodes[nid]) + target = relative_reference(file, md.get("value") or "") + if target is None or target not in self.index.by_file: + answers.append(Resolution("unavailable", reason="script_outside_corpus")) + elif md.get("import_kind") == "namespace" and len(parts) == 2: + answers.append(self._script_function(target, parts[1], external=True)) + elif md.get("import_kind") == "named" and len(parts) == 1: + answers.append(self._script_function(target, md.get("exported_name"), external=True)) + else: + answers.append(Resolution("dynamic", reason="script_namespace_or_value_is_not_callable")) + return self._combine(answers, reason="script_runtime_context_unavailable") + + @staticmethod + def _combine(results, *, reason): + results = list(results) + ids, evidence = [], [] + for result in results: + if result.target_id: + ids.append(result.target_id) + ids.extend(result.candidates) + evidence.extend(result.evidence) + selected = answer(ids, reason=reason, evidence=evidence) + if selected.status != "unavailable": + return selected + return results[0] if results else selected + + def _qml_script(self, file, component, reference): + parts, results = reference.split("."), [] + if len(parts) == 2: + for nid in self.index.imports.get(file, []): + md = qml_metadata(self.index.nodes[nid]) + if md.get("import_kind") != "script" or md.get("qualifier") != parts[0]: + continue + target = relative_reference(file, md.get("value") or "") + result = self._script_function(target, parts[1], external=True) + results.append(Resolution(result.status, result.target_id, result.reason, + result.evidence + (nid,), result.candidates)) + # Script namespaces and object types are distinct qmldir export roles. + for nid in self.index.imports.get(file, []): + md = qml_metadata(self.index.nodes[nid]) + qualifier = md.get("qualifier") or "" + wanted = parts[1:] if qualifier and parts[0] == qualifier else parts if not qualifier else [] + if len(wanted) != 2: + continue + if md.get("import_kind") == "module": + provider = self.index.module_script(md.get("value"), md.get("major"), md.get("minor"), wanted[0], importer=file) + elif md.get("import_kind") == "directory": + directory = relative_reference(file, md.get("value") or "") + provider = self.index.directory_script(directory, wanted[0], importer=file) + else: + continue + if provider.status == "resolved": + target = self.index.paths[provider.target_id] + result = self._script_function(target, wanted[1], external=True) + results.append(Resolution(result.status, result.target_id, result.reason, + result.evidence + provider.evidence + (nid,), result.candidates)) + elif provider.status == "ambiguous": + results.append(provider) + return self._combine(results, reason="script_import_not_visible") + + def chase_alias(self, result, seen=()): + if result.status != "resolved" or result.target_id not in self.aliases: + return result + if result.target_id in seen or len(seen) >= 32: + return Resolution("unsupported", reason="alias_cycle_or_limit") + sites = self.aliases[result.target_id] + if len(sites) != 1: + return Resolution("ambiguous", reason="duplicate_alias_binding", candidates=tuple(sites[:50])) + site = self.index.nodes[sites[0]] + md = qml_metadata(site) + resolved = self.resolve(site, md.get("reference") or "", seen=seen + (result.target_id,)) + return Resolution(resolved.status, resolved.target_id, resolved.reason, + tuple(dict.fromkeys(result.evidence + (site["id"],) + resolved.evidence)), resolved.candidates) + + def resolve(self, site, reference, *, seen=()): + md, file = qml_metadata(site), self.index.paths[site["id"]] + if not reference: + return Resolution("dynamic", reason=md.get("reason") or "computed_or_runtime_target") + parts = reference.split(".") + lexical = md.get("lexical_names") or [] + if md.get("lexical_shadowed") or parts[0] in lexical: + target = md.get("lexical_target_id") + if target and len(parts) == 1 and target in self.index.nodes: + return answer([target]) + return Resolution("dynamic", reason="javascript_lexical_binding") + if md.get("script_file"): + return self._script(file, reference) + component, scope = md.get("component_key"), md.get("object_scope_key") + # Local QML names shadow an imported JS qualifier, even when they cannot + # be continued statically. A computed namespace never bypasses shadowing. + head = self.index.resolve_member(file, component, scope, parts[0]) + if head.status == "ambiguous": + return head + if head.status == "resolved": + # Follow each established prefix separately so aliases inside a chain + # can be chased without pretending an alias's raw type is a provider. + result = self.chase_alias(head, seen) + for member in parts[1:]: + if result.status != "resolved": + return result + evidence = result.evidence + result = self.chase_alias(self.index.follow_member(result.target_id, [member]), seen) + result = Resolution(result.status, result.target_id, result.reason, + tuple(dict.fromkeys(evidence + result.evidence)), result.candidates) + return result + script = self._qml_script(file, component, reference) + if script.status in {"resolved", "ambiguous"} or script.evidence: + return script + result = self.index.resolve_member(file, component, scope, reference, lexical_names=lexical) + return self.chase_alias(result, seen) diff --git a/graphify/qml_relationships.py b/graphify/qml_relationships.py new file mode 100644 index 0000000000..201509783d --- /dev/null +++ b/graphify/qml_relationships.py @@ -0,0 +1,152 @@ +"""Project expression sites to proven scoped endpoints without runtime execution.""" +from __future__ import annotations + +import hashlib +from pathlib import Path + +from graphify.extractors.qml_facts import encode_metadata +from graphify.qml_relationship_lookup import RelationshipLookup +from graphify.qml_resolution import build_qml_index +from graphify.qml_resolution_types import Resolution, fact_edge, fact_node, qml_metadata + + +def _handler(lookup, site): + md, index = qml_metadata(site), lookup.index + if md.get("disabled_reason"): + return Resolution("unsupported", reason=md["disabled_reason"]) + if md.get("attached_prefix"): + return Resolution("unsupported", reason="attached_signal_provider_unavailable") + reference = md.get("reference") or "" + if md.get("connections"): + if not md.get("connection_supplied") or not md.get("connection_target"): + return Resolution("dynamic", reason="connections_runtime_target") + reference = md["connection_target"] + "." + reference + result = lookup.resolve(site, reference) + if result.status == "resolved": + kind = index.md(result.target_id).get("kind") + if kind == "signal": + return result + return Resolution("unsupported", reason="handler_target_is_not_signal") + # QML generates a notify signal for an observed source-declared property. + # Keep that inferred signal distinct from the property and its subscriptions. + if reference.endswith("Changed"): + prop = lookup.resolve(site, reference[:-7]) + if prop.status == "resolved" and index.md(prop.target_id).get("kind") == "property": + metadata = index.md(prop.target_id) + if metadata.get("qt_native"): + # Qt exposes onChanged even when the actual native + # NOTIFY signal has another name. Its source accessor supplies + # the endpoint; pure-QML inferred signals remain separate. + notify = index.native_index.property_notify(prop.target_id, metadata.get("provider_id")) + return Resolution(notify.status, notify.target_id, notify.reason, + tuple(dict.fromkeys(prop.evidence + notify.evidence)), notify.candidates) + return Resolution("resolved", prop.target_id, "property_notify_signal", prop.evidence) + return result + + +def _implicit_handler_parameter(lookup, site, cache): + """Resolve inherited signal binders before any same-name QML member fallback.""" + md, index = qml_metadata(site), lookup.index + first = (md.get("reference") or "").split(".")[0] + owner, seen = md.get("owner_id"), set() + while owner and owner in index.nodes and owner not in seen and len(seen) < 32: + seen.add(owner) + node = index.nodes[owner] + ancestor = qml_metadata(node) + if ancestor.get("kind") == "handler": + # This authoritative source flag survives bounded display metadata; + # explicit function/arrow formals never inherit signal names. + if ancestor.get("implicit_parameters") is not True: + return False + if owner not in cache: + signal = _handler(lookup, node) + cache[owner] = (index.md(signal.target_id).get("parameter_names") or [] + if signal.status == "resolved" and signal.target_id in index.nodes else []) + return first in cache[owner] + owner = ancestor.get("owner_id") + return False + + +def resolve_qml_relationships(per_file, all_nodes, all_edges, *, root: Path, import_roots=None, native_index=None, project_index=None) -> None: + """Mutate fresh source-owned sites only; prior graph context is read-only. + + Each occurrence is already a unique fact with its expression span and owner. + Separate endpoint edges preserve repeated reads/calls through Graph/DiGraph. + Unsupported/dynamic/ambiguous sites remain explicit and get no guessed edge. + """ + index = build_qml_index(all_nodes, all_edges, root=root, import_roots=import_roots, native_index=native_index, project_index=project_index) + lookup = RelationshipLookup(index) + fresh = {node["id"] for result in per_file.values() if result for node in result.get("nodes", [])} + node_ids = {node["id"] for node in all_nodes} + edge_keys = {(e["source"], e["target"], e["relation"], e.get("context")) for e in all_edges} + handler_parameters = {} + + def append_node(node): + if node["id"] not in node_ids: + all_nodes.append(node) + node_ids.add(node["id"]) + + def append_edge(edge): + key = edge["source"], edge["target"], edge["relation"], edge.get("context") + if key not in edge_keys: + all_edges.append(edge) + edge_keys.add(key) + + for nid in sorted(fresh): + site = index.nodes.get(nid) + if site is None: + continue + md = qml_metadata(site) + kind = md.get("kind") + if kind not in {"read", "call", "alias", "handler"}: + continue + if kind in {"read", "call"} and not md.get("lexical_shadowed") and _implicit_handler_parameter(lookup, site, handler_parameters): + md["lexical_shadowed"] = True + md["lexical_target_id"] = "" + result = Resolution("dynamic", reason="javascript_lexical_binding") + else: + result = _handler(lookup, site) if kind == "handler" else lookup.resolve(site, md.get("reference") or "") + if result.status == "resolved" and kind == "call": + target_kind = index.md(result.target_id).get("kind") + if target_kind not in {"function", "js_function", "qml_script_function", "signal"}: + result = Resolution("dynamic", reason="runtime_callable_value") + span = md.get("span", {}) + owner = {"source_file": index.paths[nid], "source_location": site.get("source_location", "L1")} + target = result.target_id + if result.reason == "property_notify_signal": + assert target is not None + key = hashlib.sha256(f'{target}:notify'.encode()).hexdigest() + signal = fact_node(index.paths[target], "property_change_signal", key, + (qml_metadata(index.nodes[target]).get("raw_name") or "property") + "Changed", + qml_metadata(index.nodes[target]).get("span", {}).get("start_row", 0) + 1, + property_id=target, status="inferred", span=qml_metadata(index.nodes[target]).get("span", {})) + append_node(signal) + append_edge(fact_edge(target, signal["id"], "contains", index.nodes[target], "qml_property_notify_signal")) + target = signal["id"] + updated = {k: v for k, v in md.items() if k != "raw_values"} + updated.update(status=result.status, reason=result.reason, resolved_target_id=target or "", + evidence=list(result.evidence[:50]), candidates=list(result.candidates[:50]), + diagnostic_code="" if result.status == "resolved" else "QML-EXPRESSION-001") + site["metadata"]["qml"] = encode_metadata(updated) + if result.status != "resolved": + continue + assert target is not None + relation, context = "uses", "qml_binding_read" + if kind == "alias": + relation, context = "references", "qml_alias_target" + elif kind == "handler": + relation, context = "references", "qml_signal_subscription" + elif kind == "call": + target_kind = index.md(target).get("kind") + if target_kind == "signal": + context = "qml_signal_emit" + else: + relation = "calls" + context = "qml_script_call" if target_kind == "qml_script_function" else "qml_js_call" + edge = fact_edge(nid, target, relation, owner, context, confidence="INFERRED", + span=span, evidence=list(result.evidence[:50])) + proof = native_index.endpoint_proof(target, result.evidence) if native_index else {} + if proof: + from graphify.extractors.qt_cpp_facts import encode_qt + edge["metadata"]["qt"] = encode_qt({"kind": "qml_bridge", "span": span, "native_endpoint": proof}) + append_edge(edge) diff --git a/graphify/qml_resolution.py b/graphify/qml_resolution.py new file mode 100644 index 0000000000..12065416c3 --- /dev/null +++ b/graphify/qml_resolution.py @@ -0,0 +1,115 @@ +"""Project QML lookup results through source-owned sites in Graphify's simple graph.""" + +from __future__ import annotations + +import hashlib +import json +import posixpath +from pathlib import Path + +from graphify.qml_resolution_types import Resolution, answer, fact_edge, fact_node, qml_metadata, relative_reference +from graphify.qml_scope import QmlProjectIndex + + +def build_qml_index(nodes, edges, *, root: Path, import_roots=None, native_index=None, project_index=None) -> QmlProjectIndex: + """Indexes have one run lifetime and never discover or execute additional files.""" + return QmlProjectIndex(nodes, edges, root=Path(root), import_roots=import_roots, + native_index=native_index, project_index=project_index) + + +def _import_target(index: QmlProjectIndex, node: dict, file: str): + md = qml_metadata(node) + kind, value = md.get("import_kind"), md.get("value") or "" + if kind == "module": + return index.module_import(value, md.get("major"), md.get("minor")), None + path = relative_reference(file, value) + if path is None: + return Resolution("unavailable", reason="import_outside_corpus"), None + if kind == "script": + candidates = [nid for nid in index.by_file.get(path, []) + if index.nodes[nid].get("type") == "file" or + index.nodes[nid].get("label") == posixpath.basename(path)] + return answer(candidates, reason="script_outside_corpus"), None + if kind == "directory": + known = any(posixpath.dirname(accepted) == path for accepted in index.by_file) + if not known: + return Resolution("unavailable", reason="directory_outside_corpus"), None + namespace = fact_node(file, "directory_namespace", path, value, + md.get("span", {}).get("start_row", 0) + 1, + directory=path, span=md.get("span", {})) + return Resolution("resolved", namespace["id"]), namespace + return Resolution("unsupported", reason="unsupported_import_kind"), None + + +def resolve_qml_project(per_file, all_nodes: list[dict], all_edges: list[dict], *, + root: Path, import_roots=None, native_index=None, project_index=None) -> None: + """Append derived facts only for fresh sources; borrowed context stays immutable. + + Resolved imports/types use independent sites so parallel source mechanisms + cannot overwrite each other in a simple graph. Unresolved sites retain a + bounded reason/candidate set and never receive a guessed target edge. + """ + index = build_qml_index(all_nodes, all_edges, root=root, import_roots=import_roots, + native_index=native_index, project_index=project_index) + fresh = {node["id"] for result in per_file.values() for node in result.get("nodes", [])} + known_nodes = {node["id"] for node in all_nodes} + known_edges = {(edge["source"], edge["target"], edge["relation"], edge.get("context")) + for edge in all_edges} + + def append_node(node): + if node["id"] not in known_nodes: + all_nodes.append(node) + known_nodes.add(node["id"]) + + def append_edge(edge): + key = (edge["source"], edge["target"], edge["relation"], edge.get("context")) + if key not in known_edges: + all_edges.append(edge) + known_edges.add(key) + + for node_id in sorted(fresh): + node = index.nodes.get(node_id) + if node is None: + continue + md, file = qml_metadata(node), index.paths[node_id] + kind = md.get("kind") + namespace = None + if kind == "import": + result, namespace = _import_target(index, node, file) + site_kind, relation, label = "import_resolution", "imports", md.get("value") or "import" + elif kind == "object": + label = md.get("type_name") or md.get("raw_type") or "" + result = index.resolve_type(file, md.get("component_key") or "", label) + native = native_index.metadata(result.target_id) if native_index and result.target_id else {} + if native.get("qt_native") and (not native.get("creatable") or native.get("singleton")): + result = Resolution("unsupported", reason="native_type_not_creatable", evidence=result.evidence) + site_kind, relation = "type_use", "uses" + else: + continue + if namespace is not None: + append_node(namespace) + span = md.get("span", {}) + line = span.get("start_row", 0) + 1 + # Hash a bounded semantic key; the owner edge retains the full declaration ID. + key = hashlib.sha256(json.dumps([node_id, site_kind], ensure_ascii=False).encode()).hexdigest() + site = fact_node(file, site_kind, key, label, line, span=span, + status=result.status, reason=result.reason, + diagnostic_code="QML-RESOLVE-001" if result.status != "resolved" else "", + evidence=list(result.evidence[:50]), candidates=list(result.candidates[:50])) + append_node(site) + owner = {"source_file": file, "source_location": node.get("source_location", f"L{line}")} + append_edge(fact_edge(node_id, site["id"], "contains", owner, "qml_resolution_site", span=span)) + if result.target_id is not None: + target_proof = {} + if kind == "import" and md.get("import_kind") == "script": + target_proof = {"target_role": "script_file", "target_file_id": result.target_id, + "target_file": index.paths[result.target_id]} + edge = fact_edge(site["id"], result.target_id, relation, owner, + "qml_import_resolution" if kind == "import" else "qml_type_resolution", + confidence="INFERRED", span=span, evidence=list(result.evidence[:50]), **target_proof) + proof = native_index.endpoint_proof(result.target_id, result.evidence, + kind="module" if kind == "import" else "component") if native_index else {} + if proof: + from graphify.extractors.qt_cpp_facts import encode_qt + edge["metadata"]["qt"] = encode_qt({"kind": "qml_bridge", "span": span, "native_endpoint": proof}) + append_edge(edge) diff --git a/graphify/qml_resolution_types.py b/graphify/qml_resolution_types.py new file mode 100644 index 0000000000..f48d79245b --- /dev/null +++ b/graphify/qml_resolution_types.py @@ -0,0 +1,98 @@ +"""Portable Qt/QML fact and resolution values; no corpus discovery or execution.""" + +from __future__ import annotations + +import json +import posixpath +from dataclasses import dataclass +from pathlib import Path + +from graphify.extractors.qml_facts import encode_metadata, make_qml_id +from graphify.extractors.qml_facts import qml_metadata as literal_qml_metadata + + +@dataclass(frozen=True) +class Resolution: + """One scoped answer, including why a concrete endpoint was not selected.""" + + status: str + target_id: str | None = None + reason: str = "" + evidence: tuple[str, ...] = () + candidates: tuple[str, ...] = () + + def __post_init__(self): + if (self.status == "resolved") != (self.target_id is not None): + raise ValueError("QML_RESOLUTION: target must exist exactly for resolved answers") + + +def answer(candidates, *, reason="missing", evidence=()) -> Resolution: + """Deduplicate exact declarations; never choose an arbitrary competing target.""" + ids = tuple(sorted(set(candidates))) + if len(ids) == 1: + return Resolution("resolved", ids[0], evidence=tuple(sorted(set(evidence)))) + if ids: + return Resolution("ambiguous", reason="duplicate_provider", candidates=ids[:50]) + return Resolution("unavailable", reason=reason, evidence=tuple(evidence)) + + +def qml_metadata(node: dict) -> dict: + value = literal_qml_metadata(node) + return value if value.get("contract_version") == 1 else {} + + +def source_path(node: dict, root: Path) -> str | None: + """Normalize an accepted fact's provenance without reading another source file.""" + raw = str(node.get("source_file") or "").replace("\\", "/") + if not raw: + return None + path = Path(raw) + if path.is_absolute(): + try: + raw = path.relative_to(root).as_posix() + except ValueError: + return None + raw = posixpath.normpath(raw) + if raw == ".." or raw.startswith("../") or raw.startswith("/") or ":" in raw: + return None + return raw + + +def relative_reference(source: str, value: str) -> str | None: + """Resolve only a local literal path; corpus membership is checked separately.""" + value = value.replace("\\", "/") + if not value or value.startswith("/") or ":" in value: + return None + result = posixpath.normpath(posixpath.join(posixpath.dirname(source), value)) + return None if result == ".." or result.startswith("../") else result + + +def fact_node(source: str, kind: str, key, label: str, line: int, **fields) -> dict: + """Mint exact-spelling salted IDs before Graphify's casefold normalization.""" + semantic_key = json.dumps(key, ensure_ascii=False, separators=(",", ":")) + return { + "id": make_qml_id(source, kind, semantic_key), + "label": label, + "file_type": "code", + "type": "file" if kind == "file" else "namespace", + "source_file": source, + "source_location": f"L{line}", + "_origin": "ast", + "metadata": {"qml": encode_metadata({"kind": kind, **fields})}, + } + + +def fact_edge(source: str, target: str, relation: str, owner: dict, context: str, + *, confidence: str = "EXTRACTED", **metadata) -> dict: + return { + "source": source, + "target": target, + "_src": source, + "_tgt": target, + "relation": relation, + "confidence": confidence, + "source_file": owner["source_file"], + "source_location": owner.get("source_location", "L1"), + "context": context, + "metadata": {"qml": encode_metadata(metadata)}, + } diff --git a/graphify/qml_safety.py b/graphify/qml_safety.py new file mode 100644 index 0000000000..4473c1196d --- /dev/null +++ b/graphify/qml_safety.py @@ -0,0 +1,114 @@ +"""Guard publication of Qt/QML extraction and conservative incremental refresh. + +The graph writer owns persistence. These read-only checks run before graph +reconciliation or publication: a count-based shrink guard cannot detect lost +edges, replacement by unrelated nodes, or incomplete QML component scopes. +""" +from __future__ import annotations + +import json +from collections.abc import Iterable +from pathlib import Path + + +class QmlSafetyError(RuntimeError): + """The candidate lacks authoritative Qt/QML facts and cannot be published.""" + + +def is_qml_path(path: str | Path) -> bool: + """Recognize QML source and its named/type-description metadata.""" + p = Path(path) + return p.suffix.lower() in {".qml", ".qmltypes", ".cmake", ".pro", ".pri", ".qrc"} or p.name in {"qmldir", "CMakeLists.txt"} + + +def qml_refresh_required( + corpus_paths: Iterable[str | Path], changed_paths: Iterable[str | Path] +) -> bool: + """Refresh the live code corpus when QML or its JS inputs may change scope. + + Until dependency-directed invalidation exists, include unchanged sources + rather than reuse project-context edges from an obsolete component scope. + Deleted/renamed QML paths count even when the live corpus has none left. + """ + from graphify.qt_incremental import plan_qt_refresh + return plan_qt_refresh(corpus_paths, changed_paths).required + + +def require_qml_watch_root( + paths: Iterable[str | Path], *, project_root: Path, watch_root: Path +) -> None: + """Reject the legacy subfolder path-only rebase for nested QML fact IDs.""" + if any(is_qml_path(p) for p in paths) and project_root != watch_root: + raise QmlSafetyError( + "QML_ROOT_MISMATCH: Qt/QML subfolder updates cannot rebase scoped " + "fact IDs safely. Existing graph and manifest were preserved. " + "Run graphify update with the absolute project scan root." + ) + + +def _identity(path: str | Path, root: Path | None) -> str: + p = Path(path) + if root is not None and not p.is_absolute(): + p = root / p + try: + return p.resolve().as_posix() + except (OSError, RuntimeError): + return p.as_posix() + + +def _display(path: str | Path, root: Path | None) -> str: + """Bound diagnostics and escape delimiters without disclosing foreign roots.""" + p = Path(path) + if root is not None and p.is_absolute(): + try: + p = p.relative_to(root) + except ValueError: + p = Path(p.name) + elif p.is_absolute(): + p = Path(p.name) + return json.dumps(p.as_posix()[:160], ensure_ascii=True) + + +def require_complete_qml( + result: dict, paths: Iterable[str | Path], *, operation: str, + root: Path | None = None, +) -> None: + """Reject failed, partial, or omitted QML contributions before any commit. + + Error paths are authoritative even if unrelated additions conceal a loss. + No force/partial flag bypasses this contract. Adapter diagnostics remain + separate; this bounded message owns graph retention and the retry action. + """ + qml_paths = [Path(p) for p in paths if is_qml_path(p)] + failures = [ + Path(p) for p in result.get("failed_sources", []) if is_qml_path(p) + ] + for failure in [*(result.get("qml_failures", []) or []), *(result.get("qt_failures", []) or [])]: + if isinstance(failure, dict) and failure.get("source_file"): + failures.append(Path(failure["source_file"])) + unsafe = bool(result.get("qml_failures") or result.get("qt_failures")) or ( + bool(qml_paths) and bool( + result.get("error") or result.get("partial") or result.get("parse_errors") + ) + ) + # Every dispatched source needs an owned contribution; the adapter reports + # incomplete syntax separately. An omitted source cannot become a successful + # empty result merely because its failure marker was lost by a producer. + represented = { + _identity(n["source_file"], root) + for n in result.get("nodes", []) + if isinstance(n, dict) and n.get("source_file") + } + failures.extend(p for p in qml_paths if _identity(p, root) not in represented) + if not failures and not unsafe: + return + shown = sorted({_display(p, root) for p in failures or qml_paths}) + names = ", ".join(shown[:6]) + if len(shown) > 6: + names += f" (+{len(shown) - 6} more)" + raise QmlSafetyError( + f"QML_GRAPH_PRESERVED: {operation} rejected incomplete Qt/QML extraction" + f"{': ' + names if names else ''}. Existing graph and manifest were " + "preserved. Fix the reported parser/source failure and retry; force " + "and partial-output flags do not bypass this QML integrity gate." + ) diff --git a/graphify/qml_scope.py b/graphify/qml_scope.py new file mode 100644 index 0000000000..ec8965507e --- /dev/null +++ b/graphify/qml_scope.py @@ -0,0 +1,208 @@ +"""Component/object/member lookup; dynamic instance ancestry is intentionally absent.""" + +from __future__ import annotations + +import posixpath + +from graphify.qml_module_index import QmlModuleIndex, combine +from graphify.qml_resolution_types import Resolution, answer, qml_metadata, relative_reference + + +class QmlProjectIndex(QmlModuleIndex): + """Module imports and lexical declarations share one bounded, per-run owner.""" + + def __init__(self, nodes, edges, **kwargs): + super().__init__(nodes, edges, **kwargs) + self.objects, self.members, self.inline = {}, {}, {} + for nid, node in self.nodes.items(): + md, path = qml_metadata(node), self.paths[nid] + component = md.get("component_key", "") + scope = md.get("object_scope_key", "") + if md.get("kind") == "object": + self.objects.setdefault((path, component, scope), []).append(nid) + elif md.get("kind") in {"property", "function", "signal", "enum"}: + self.members.setdefault((path, component, scope, md.get("raw_name")), []).append(nid) + elif md.get("kind") == "inline_component": + enclosing = md.get("enclosing_component_key") or md.get("parent_component_key") + if enclosing is None: + owners = [qml_metadata(self.nodes[parent]).get("component_key") + for parent in self.parents.get(nid, ()) if parent in self.nodes] + enclosing = owners[0] if len(set(owners)) == 1 else None + self.inline.setdefault((path, enclosing, md.get("raw_name")), []).append(nid) + + def module_import(self, uri, major=None, minor=None): + results = [super().module_import(uri, major, minor)] + if self.native_index: + results.append(self.native_index.module_import(uri, major, minor)) + if self.project_index: + results.append(self.project_index.module_import(uri, major, minor)) + # One build namespace and its native registrations describe the same + # module when the exact context node is carried in native evidence. + resolved = [result for result in results if result.target_id] + if len(resolved) > 1: + namespaces = [result for result in resolved if any(result.target_id in other.evidence + for other in resolved if other is not result)] + if len(namespaces) == 1: + return Resolution("resolved", namespaces[0].target_id, + evidence=tuple(sorted({item for result in resolved for item in result.evidence}))) + return combine(results) + + def module_type(self, uri, major, minor, name, *, importer, **kwargs): + results = [super().module_type(uri, major, minor, name, importer=importer, **kwargs)] + if kwargs.get("_export_kind", "type") == "type": + if self.native_index: + results.append(self.native_index.module_type(uri, major, minor, name, importer=importer)) + if self.project_index: + results.append(self.project_index.resolve_component(uri, name, major, minor)) + return combine(results) + + def resolve_type(self, file: str, component_key: str, name: str) -> Resolution: + """Inline names shadow imports; an import qualifier is document-local.""" + parts = name.split(".") + local = self.inline.get((file, component_key, name), []) + if local: + return answer(local) + results = [] + for import_id in self.imports.get(file, []): + md = qml_metadata(self.nodes[import_id]) + qualifier = md.get("qualifier") or "" + if qualifier: + if len(parts) != 2 or parts[0] != qualifier: + continue + wanted = parts[1] + elif len(parts) == 1: + wanted = name + else: + continue + value = str(md.get("value") or "") + kind = md.get("import_kind") + if kind == "module": + result = self.module_type(value, md.get("major"), md.get("minor"), wanted, importer=file) + elif kind == "directory": + directory = relative_reference(file, value) + result = (self.directory_type(directory, wanted, importer=file) if directory is not None else + Resolution("unavailable", reason="directory_outside_corpus")) + else: + continue + if result.status == "resolved": + result = Resolution("resolved", result.target_id, evidence=result.evidence + (import_id,)) + results.append(result) + if len(parts) == 1: + # Implicit local-directory visibility cannot become global basename search. + results.append(self.directory_type(posixpath.dirname(file), name, importer=file)) + return combine(results) + + def _root_objects(self, component_id: str) -> list[str]: + if self.native_index and self.native_index.is_provider(component_id): + return [component_id] + md = qml_metadata(self.nodes[component_id]) + component = md.get("component_key") + file = self.paths[component_id] + candidates = [] + for (path, key, _), ids in self.objects.items(): + if path != file or key != component: + continue + for nid in ids: + if component_id in self.parents.get(nid, ()) or not qml_metadata(self.nodes[nid]).get("parent_scope_key"): + candidates.append(nid) + return sorted(candidates) + + def _object_member(self, object_id: str, name: str, seen=()) -> Resolution: + if self.native_index and self.native_index.is_provider(object_id): + return self.native_index.member(object_id, name) + if object_id in seen or len(seen) >= 32: + return Resolution("unsupported", reason="inheritance_cycle_or_limit") + md = qml_metadata(self.nodes[object_id]) + file, component, scope = self.paths[object_id], md.get("component_key"), md.get("object_scope_key") + local = self.members.get((file, component, scope, name), []) + if local: + visible = [nid for nid in local if not qml_metadata(self.nodes[nid]).get("internal")] + return answer(visible, reason="member_not_visible") + type_name = md.get("type_name") or md.get("raw_type") + if not type_name: + return Resolution("unavailable", reason="receiver_type_unknown") + base = self.resolve_type(file, component or "", type_name) + if base.target_id is None: + return base + return combine([self._object_member(nid, name, seen + (object_id,)) + for nid in self._root_objects(base.target_id)], reason="member_unavailable") + + def resolve_member(self, file: str, component_key: str, object_scope_key: str, + name: str, *, lexical_names=()) -> Resolution: + """IDs precede properties; lexical JS bindings prevent any QML fallback.""" + parts = name.split(".") + if parts[0] in lexical_names: + return Resolution("dynamic", reason="javascript_lexical_binding") + if parts[0] in {"this", "parent"}: + objects = self.objects.get((file, component_key, object_scope_key), []) + if parts[0] == "parent": + scopes = [qml_metadata(self.nodes[nid]).get("static_parent_scope_key") for nid in objects] + objects = [nid for scope in scopes if scope for nid in + self.objects.get((file, component_key, scope), [])] + owner = answer(objects, reason="runtime_parent_unestablished" if parts[0] == "parent" else + "receiver_scope_unavailable") + return (self.follow_member(owner.target_id, parts[1:]) if owner.target_id is not None and + len(parts) > 1 else owner) + candidates = [] + for (path, component, _), ids in self.objects.items(): + if path == file and component == component_key: + candidates.extend(nid for nid in ids if qml_metadata(self.nodes[nid]).get("object_id") == parts[0]) + if candidates: + target = answer(candidates) + if len(parts) == 1 or target.target_id is None: + return target + return self._follow_member(target.target_id, parts[1:]) + if len(parts) > 1: + # An own/root property precedes a type namespace with the same spelling. + head = self.resolve_member(file, component_key, object_scope_key, parts[0]) + if head.target_id is not None: + return self.follow_member(head.target_id, parts[1:]) + if head.status == "ambiguous": + return head + target = self.resolve_type(file, component_key, parts[0]) + if target.target_id is not None: + md = self.md(target.target_id) + if not md.get("singleton"): + return Resolution("unsupported", reason="type_is_not_instance") + return combine([self._follow_member(nid, parts[1:]) for nid in self._root_objects(target.target_id)]) + # Qualified imported singleton (Alias.State.value). + if len(parts) > 2: + target = self.resolve_type(file, component_key, ".".join(parts[:2])) + if target.target_id is not None and self.md(target.target_id).get("singleton"): + return combine([self._follow_member(nid, parts[2:]) for nid in self._root_objects(target.target_id)]) + if target.status == "resolved": + return Resolution("unsupported", reason="type_is_not_instance") + return target + own_objects = self.objects.get((file, component_key, object_scope_key), []) + own = combine([self._object_member(nid, name) for nid in own_objects]) + if own.status in {"resolved", "ambiguous"}: + return own + roots = [nid for nid, node in self.nodes.items() if self.paths[nid] == file and + qml_metadata(node).get("kind") in {"component", "inline_component"} and + qml_metadata(node).get("component_key") == component_key] + return combine([self._object_member(nid, name) for root in roots for nid in self._root_objects(root)], + reason="runtime_context_or_member_unavailable") + + def _follow_member(self, object_id: str, parts: list[str]) -> Resolution: + target = self._object_member(object_id, parts[0]) + if len(parts) == 1 or target.target_id is None: + return target + return self.follow_member(target.target_id, parts[1:]) + + def follow_member(self, target_id: str, parts) -> Resolution: + """Continue an established object/typed-property target, without alias execution.""" + parts = list(parts) + if len(parts) > 32: + return Resolution("unsupported", reason="member_chain_limit") + if not parts: + return answer([target_id]) + if self.native_index and self.native_index.is_provider(target_id): + return self._follow_member(target_id, parts) + md = qml_metadata(self.nodes[target_id]) + if md.get("kind") == "object": + return self._follow_member(target_id, parts) + provider = self.resolve_type(self.paths[target_id], md.get("component_key") or "", + md.get("raw_type") or "") + if provider.target_id is None: + return provider + return combine([self._follow_member(nid, parts) for nid in self._root_objects(provider.target_id)]) diff --git a/graphify/qt_affected.py b/graphify/qt_affected.py new file mode 100644 index 0000000000..c06f06a9b2 --- /dev/null +++ b/graphify/qt_affected.py @@ -0,0 +1,64 @@ +"""Source ownership propagation for Qt/QML dependency occurrence nodes.""" +from __future__ import annotations + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.graph_direction import logical_endpoints + + +def _owned(node): + try: + return qml_metadata(node).get("contract_version") == 1 or qt_metadata(node).get("contract_version") == 1 + except (ValueError, TypeError): + return False + + +def _owns_source(parent, child, data, owner_id, child_id): + file = child.get("source_file") + if not file or data.get("source_file") != file: + return False + if parent.get("source_file") == file: + return True + # C++ canonical methods point at their header declaration. Their accepted + # implementation file can own a Qt site only with callable and exact owner, + # endpoint and original-span evidence from the extracted containment fact. + if parent.get("_callable") is not True or parent.get("definition_file") != file: + return False + child_qt, edge_qt = qt_metadata(child), qt_metadata(data) + return (child_qt.get("owner_id") == owner_id and edge_qt.get("target_id") == child_id + and bool(child_qt.get("span")) and child_qt.get("span") == edge_qt.get("span")) + + +def owned_ancestors(graph, node_id, *, limit=256): + """Yield actual EXTRACTED contains owners without general forward traversal. + + A dependency points from a source occurrence to its target. Its enclosing + binding/handler/component is affected through that source ownership, not by + an inferred signal-delivery call. Ordinary graph nodes retain old behavior. + """ + pending, seen = [node_id], {node_id} + while pending and len(seen) <= limit: + current = pending.pop() + if current not in graph or not _owned(graph.nodes[current]): + continue + edges = graph.in_edges(current, data=True) if graph.is_directed() else graph.edges(current, data=True) + for source, target, data in edges: + endpoints = logical_endpoints(source, target, data, directed=graph.is_directed()) + if endpoints is None: + continue + source, target = endpoints + if target != current or source not in graph or source in seen: + continue + if data.get("relation") != "contains" or data.get("confidence") != "EXTRACTED": + continue + parent, child = graph.nodes[source], graph.nodes[current] + try: + if not _owned(data) or not _owns_source(parent, child, data, source, current): + continue + except (ValueError, TypeError): + continue + if len(seen) >= limit: + return + seen.add(source) + yield str(source), data + pending.append(source) diff --git a/graphify/qt_analysis_state.py b/graphify/qt_analysis_state.py new file mode 100644 index 0000000000..231a3b3f54 --- /dev/null +++ b/graphify/qt_analysis_state.py @@ -0,0 +1,173 @@ +"""Bounded Qt analysis stamps; the caller owns successful publication ordering.""" +from __future__ import annotations + +import json +import hashlib +import os +import re +import posixpath +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +from graphify.extractors.qml_facts import CONTRACT_VERSION +from graphify.paths import write_text_atomic +from graphify.qt_incremental import QT_CPP_SUFFIXES, is_qt_metadata, is_qt_source, qt_analysis_fingerprint + +QT_STATE_FILE = ".qt_analysis.json" +QT_STATE_SCHEMA = 1 +_DIGEST = re.compile(r"[0-9a-f]{64}\Z") +MAX_CONFIG_BYTES = 1_048_576 + + +@dataclass(frozen=True) +class QtAnalysisState: + fingerprint: str + changed: bool + import_roots: tuple[str, ...] + has_qt: bool + current_has_qt: bool + + +def _read_state(output_root: Path) -> dict | None: + path = Path(output_root) / QT_STATE_FILE + try: + if path.stat().st_size > 1024: + return None + with path.open("rb") as stream: + raw = stream.read(1025) + if len(raw) > 1024: + return None + value = json.loads(raw.decode("utf-8")) + except (OSError, UnicodeError, ValueError): + return None + if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != QT_STATE_SCHEMA: + return None + if not _valid_fingerprint(value.get("fingerprint")) or not isinstance(value.get("has_qt", False), bool): + return None + return value + + +def _valid_fingerprint(value) -> bool: + return isinstance(value, str) and _DIGEST.fullmatch(value) is not None + + +def read_qt_fingerprint(output_root: Path) -> str | None: + """Missing, incompatible and corrupted state force refresh, never cache reuse.""" + value = _read_state(output_root) + return value["fingerprint"] if value else None + + +def qt_configuration_changed(output_root: Path, fingerprint: str) -> bool: + if not _valid_fingerprint(fingerprint): + raise ValueError("QT_CONFIG: invalid analysis fingerprint") + return read_qt_fingerprint(output_root) != fingerprint + + +def save_qt_fingerprint(output_root: Path, fingerprint: str, *, has_qt=False) -> None: + """Call only after graph/manifest publication succeeds; no automatic writeback.""" + if not _valid_fingerprint(fingerprint): + raise ValueError("QT_CONFIG: invalid analysis fingerprint") + path = Path(output_root) / QT_STATE_FILE + path.parent.mkdir(parents=True, exist_ok=True) + write_text_atomic(path, json.dumps({"schema": QT_STATE_SCHEMA, "fingerprint": fingerprint, "has_qt": has_qt}, + sort_keys=True) + "\n") + + +def _import_roots(root: Path) -> tuple[str, ...]: + raw = os.environ.get("GRAPHIFY_QML_IMPORT_ROOTS", '["."]') + if len(raw.encode("utf-8")) > 65536: + raise ValueError("QT_CONFIG_LIMIT: import root configuration exceeds its byte bound") + try: + values = json.loads(raw) + except ValueError as exc: + raise ValueError("QT_CONFIG: import roots must be a JSON list") from exc + if not isinstance(values, list) or len(values) > 256: + raise ValueError("QT_CONFIG: import roots must be a bounded JSON list") + roots = [] + for value in values: + if not isinstance(value, str) or not value or len(value) > 384: + raise ValueError("QT_CONFIG: malformed import root") + value = value.replace("\\", "/") + if value.startswith("/") or ":" in value or "\x00" in value or ".." in value.split("/"): + raise ValueError("QT_CONFIG: import roots must stay project-relative") + try: + (root / value).resolve().relative_to(root) + except ValueError as exc: + raise ValueError("QT_CONFIG: import root resolves outside the project") from exc + roots.append(posixpath.normpath(value)) + return tuple(roots) + + +def _package_versions() -> str: + versions = {} + for package in ("graphifyy", "tree-sitter", "tree-sitter-cpp", "tree-sitter-javascript", + "tree-sitter-language-pack"): + try: + versions[package] = version(package) + except PackageNotFoundError: + versions[package] = "absent" + return json.dumps(versions, sort_keys=True) + + +def inspect_qt_analysis(root: Path, out: Path, accepted_paths, *, excludes=(), gitignore=True) -> QtAnalysisState: + """Inspect only accepted paths/ancestors, preserving root and configuration scope. + + Configured import roots do not authorize a scan. Their ordered names and + parser contracts affect the stamp; corpus admission remains caller-owned. + """ + root = Path(root).resolve() + roots = _import_roots(root) + paths, directories = [], {root} + for raw in accepted_paths: + path = Path(raw) + path = path if path.is_absolute() else root / path + try: + path = path.resolve() + relative = path.relative_to(root) + except ValueError as exc: + raise ValueError("QT_CONFIG: accepted input resolves outside the project") from exc + paths.append(relative.as_posix()) + if relative.as_posix() == ".": + raise ValueError("QT_CONFIG: an accepted input must identify a source file") + parent = path.parent + while parent != root: + directories.add(parent) + parent = parent.parent + if len(paths) > 100_000 or len(directories) > 100_000: + raise ValueError("QT_CONFIG_LIMIT: accepted input inventory exceeds its bound") + configs = [] + names = (".graphifyignore", ".gitignore") if gitignore else (".graphifyignore",) + for directory in sorted(directories): + for name in names: + path = directory / name + try: + path.resolve().relative_to(root) + with path.open("rb") as stream: + raw = stream.read(MAX_CONFIG_BYTES + 1) + except FileNotFoundError: + continue + except (OSError, ValueError) as exc: + raise ValueError("QT_CONFIG: ignore configuration is unreadable or outside root") from exc + if len(raw) > MAX_CONFIG_BYTES: + raise ValueError("QT_CONFIG_LIMIT: ignore configuration exceeds its byte bound") + configs.append((path.relative_to(root).as_posix(), hashlib.sha256(raw).hexdigest())) + config = {"accepted_paths": sorted(set(paths)), "ignore_files": configs, + "gitignore": bool(gitignore), "qt_fact_contract": 1} + profile = "qt6-static:" + hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest() + fingerprint = qt_analysis_fingerprint(parser_version=_package_versions(), fact_version=CONTRACT_VERSION, + import_roots=roots, ignore_patterns=excludes, profile=profile) + previous = _read_state(out) + current_has_qt = any(is_qt_source(path) or is_qt_metadata(path) or Path(path).suffix.lower() in QT_CPP_SUFFIXES + for path in paths) + return QtAnalysisState(fingerprint, previous is None or previous["fingerprint"] != fingerprint, + roots, current_has_qt or bool(previous and previous.get("has_qt")), current_has_qt) + + +def commit_qt_analysis(out: Path, state: QtAnalysisState) -> None: + """Prepare the candidate stamp for its owner's accepted product cohort. + + Manual/watch publication supplies its staging directory and commits the stamp + with graph/root/manifest products; writing this candidate alone accepts no run. + """ + save_qt_fingerprint(out, state.fingerprint, has_qt=state.current_has_qt) diff --git a/graphify/qt_context_bindings.py b/graphify/qt_context_bindings.py new file mode 100644 index 0000000000..4d0ce5bfb2 --- /dev/null +++ b/graphify/qt_context_bindings.py @@ -0,0 +1,217 @@ +"""Source-established engine/component providers; no repository-wide injection.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import encode_qt, qt_edge, qt_id, qt_metadata, update_qt +from graphify.extractors.qt_cpp_identity import source_reference_key +from graphify.qml_resolution_types import qml_metadata +from graphify.qt_event_index import QtEventIndex +from graphify.qt_declared_provider import DeclaredProviderIndex +from graphify.qt_context_paths import context_path +from graphify.qt_context_subscriptions import project_context_subscriptions + + +def _binding(site, item, component, provider, evidence): + metadata, source = qt_metadata(site), site["source_file"] + return {"id": qt_id(source, "context_binding", [site["id"], item.get("name"), item.get("ordinal", 0), provider]), + "label": "Qt provider: " + (item.get("name") or "context object"), "type": "concept", "file_type": "code", + "source_file": source, "source_location": site["source_location"], "_origin": "ast", + "metadata": {"qt": encode_qt({"kind": "context_binding", "span": metadata["span"], "owner_id": site["id"], + "exposed_name": item.get("name") or "", "context_object": metadata.get("operation") == "setContextObject", + "initial_property": metadata.get("kind") != "context_exposure", "provider_target_id": provider, + "declared_property_id": item.get("declared_property_id", ""), + "provider_class_name": item.get("provider_type", ""), "component_target_id": component, + "engine_reference": metadata.get("engine_reference") or metadata.get("receiver_reference") or "", + "engine_declaration_id": metadata.get("engine_declaration_id") or metadata.get("receiver_declaration_id") or "", + "provider_declaration_id": item.get("provider_declaration_id") or "", + "provider_type_evidence": item.get("provider_type_evidence", []), + "evidence": evidence, + "status": "resolved", "bridge_direction": "cpp_to_qml"})}} + + +def resolve_context_bindings(results, index, all_nodes, all_edges): + native, loads = QtEventIndex(all_nodes), {} + declared_types = DeclaredProviderIndex(all_nodes) + for result in results: + for site in result.get("nodes", []) if result else []: + metadata = qt_metadata(site) + if metadata.get("kind") == "qml_load": + # Components have their own declaration and an explicit source + # engine; either role can supply its corresponding provider. + keys = {key for role in ("receiver", "engine") + if (key := source_reference_key(metadata, role)) is not None} + for key in keys: + loads.setdefault(key, []).append(site) + additions, fresh_ids = [], set() + for result in results: + if not result: + continue + for site in list(result.get("nodes", [])): + metadata = qt_metadata(site) + if metadata.get("kind") not in {"context_exposure", "initial_properties", "qml_root"}: + continue + if metadata.get("kind") == "qml_root" and metadata.get("operation") != "createWithInitialProperties": + continue + if metadata.get("kind") != "context_exposure" and not metadata.get("literal_properties"): + update_qt(site, status="dynamic", reason="computed_initial_properties") + continue + role = "engine" if metadata.get("kind") == "context_exposure" else "receiver" + key = source_reference_key(metadata, role) + candidates = loads.get(key, []) if key is not None else [] + if key is None: + # Preserve authoritative lexical uncertainty instead of hiding + # a reassignment or duplicate behind a generic missing loader. + status = metadata.get(role + "_identity_status") + update_qt(site, status=status if status in {"dynamic", "ambiguous", "unsupported"} else "unavailable", + reason=metadata.get(role + "_identity_reason") or "provider_component_scope_unestablished") + continue + # A direct context alias without established engine identity remains + # unsupported. An old/later repeated load cannot prove one instance. + if len(candidates) != 1 or metadata.get("conditional"): + update_qt(site, status="dynamic" if metadata.get("conditional") else "ambiguous" if len(candidates) > 1 else "unavailable", reason="provider_component_scope_unestablished") + continue + load = candidates[0] + component = qt_metadata(load).get("target_id") + if not component: + continue + if metadata.get("kind") == "context_exposure": + items = [{"name": metadata.get("exposed_name"), "provider_type": metadata.get("provider_type"), + "provider_reference": metadata.get("provider_reference"), + "provider_declaration_id": metadata.get("provider_declaration_id"), + "provider_identity_status": metadata.get("provider_identity_status"), + "provider_identity_reason": metadata.get("provider_identity_reason"), + "provider_scope_end": metadata.get("provider_scope_end"), + "provider_root_type_id": metadata.get("provider_root_type_id"), + "provider_root_type_spelling": metadata.get("provider_root_type_spelling"), + "provider_root_address_of": metadata.get("provider_root_address_of"), + "provider_expression_steps": metadata.get("provider_expression_steps"), + "provider_type_status": metadata.get("provider_type_status")}] + if metadata.get("operation") == "setContextProperty" and not isinstance(metadata.get("exposed_name"), str): + update_qt(site, status="dynamic", reason="computed_context_name") + continue + else: + items = metadata.get("properties", []) + count = 0 + for item in items: + # A provider declaration must still belong to this lexical + # lifetime when its engine loads the component. Names alone and + # old transport cannot authorize a cross-language relationship. + provider_key = source_reference_key({**metadata, "provider_declaration_id": item.get("provider_declaration_id")}, "provider") + scope_end = item.get("provider_scope_end") + if (provider_key is None or not isinstance(scope_end, int) or isinstance(scope_end, bool) + or qt_metadata(load)["span"]["start_byte"] >= scope_end): + continue + if metadata.get("kind") != "context_exposure": + root = index.root_object(component) + declared_property = index.member(root.target_id, item.get("name", ""), "property") if root.target_id else root + if not declared_property.target_id: + continue + item = {**item, "declared_property_id": declared_property.target_id} + if metadata.get("operation") == "setInitialProperties" and metadata["span"]["start_byte"] > qt_metadata(load)["span"]["start_byte"]: + continue + typed = declared_types.provider(item) if item.get("provider_root_type_id") else None + declared = native.class_name(item.get("provider_type") or "") + records = ([typed[0]] if typed else [] if item.get("provider_type_status") is not None + else native.classes.get(declared, [])) + if len(records) != 1: + continue + provider = qt_metadata(records[0]) + if not provider.get("is_qobject") or not provider.get("generic_target_id"): + continue + item = {**item, "provider_type": provider["class_name"], "provider_type_evidence": typed[1] if typed else [records[0]["id"]]} + node = _binding(site, item, component, provider["generic_target_id"], [site["id"], load["id"], records[0]["id"], component] + item["provider_type_evidence"]) + existing = next((value for value in result.get("nodes", []) if value["id"] == node["id"]), None) + if existing is None: + result.setdefault("nodes", []).append(node) + additions.append(node) + fresh_ids.add(node["id"]) + edges = [qt_edge(site, node["id"], "contains", "qt_context_binding"), + qt_edge(node, component, "uses", "qt_initial_property" if metadata.get("kind") != "context_exposure" else "qt_context_exposure", bridge_direction="cpp_to_qml")] + known = {(edge["source"], edge["target"], edge.get("context")) for edge in result.get("edges", [])} + fresh_edges = [edge for edge in edges if (edge["source"], edge["target"], edge.get("context")) not in known] + result.setdefault("edges", []).extend(fresh_edges) + all_edges.extend(fresh_edges) + count += 1 + if count: + update_qt(site, status="resolved", reason="", component_target_id=component, binding_count=count) + elif items: + # Initial-property lists may mix independent failure reasons. + # Report one provider identity reason only when every rejected + # item proves that exact failure; other rejections stay generic. + failures = {(item.get("provider_identity_status"), item.get("provider_identity_reason")) for item in items} + if len(failures) == 1: + status, reason = next(iter(failures)) + if status in {"dynamic", "ambiguous", "unsupported", "unavailable"} and reason and all( + source_reference_key({**metadata, "provider_declaration_id": item.get("provider_declaration_id")}, "provider") is None + for item in items + ): + update_qt(site, status=status, reason=reason) + all_nodes.extend(additions) + additions = [] + bindings = list({node["id"]: node for node in all_nodes if qt_metadata(node).get("kind") == "context_binding"}.values()) + groups = {} + for binding in bindings: + metadata = qt_metadata(binding) + groups.setdefault((metadata.get("component_target_id"), metadata.get("exposed_name"), metadata.get("context_object")), []).append(binding) + blocked = set() + for group in groups.values(): + if len(group) > 1: + blocked.update(binding["id"] for binding in group) + for binding in group: + if binding["id"] in fresh_ids: + update_qt(binding, status="ambiguous", reason="duplicate_context_provider") + return [binding for binding in bindings if binding["id"] not in blocked] + + +def project_context_access(results, bridge, bindings, all_nodes, all_edges, qml_index): + """Join only unresolved source QML sites in the established loaded component.""" + project_context_subscriptions(results, bridge, bindings, all_nodes, all_edges, qml_index) + by_component = {} + node_map = {node["id"]: node for node in all_nodes} + for binding in bindings: + metadata = qt_metadata(binding) + if metadata.get("status") != "resolved": + continue + component = node_map.get(metadata.get("component_target_id"), {}) + key = (component.get("source_file"), qml_metadata(component).get("component_key")) + by_component.setdefault(key, []).append(binding) + for result in results: + if not result: + continue + for source in list(result.get("nodes", [])): + metadata = qml_metadata(source) + if metadata.get("kind") not in {"read", "call"} or metadata.get("status") != "unavailable" or metadata.get("lexical_shadowed") or metadata.get("script_file"): + continue + reference = metadata.get("reference", "").split(".") + imports = qml_index.imports.get(source["source_file"], []) + if any(qml_metadata(qml_index.nodes[nid]).get("qualifier") == reference[0] for nid in imports): + continue + matches = [] + for binding in by_component.get((source["source_file"], metadata.get("component_key")), []): + provider = qt_metadata(binding) + local = qml_index.resolve_member(source["source_file"], metadata.get("component_key") or "", metadata.get("object_scope_key") or "", reference[0]) + if local.status in {"resolved", "ambiguous", "unsupported", "dynamic"} and not (provider.get("initial_property") and local.target_id == provider.get("declared_property_id")): + continue + path = reference if provider.get("context_object") else reference[1:] if reference[0] == provider.get("exposed_name") else [] + if path: + resolution, proof = context_path(bridge, binding, path) + if resolution.target_id: + matches.append((binding, resolution, proof)) + if len(matches) != 1: + continue + binding, resolution, proof = matches[0] + span = metadata.get("span", {}) + site = {"id": qt_id(source["source_file"], "context_access", [source["id"], binding["id"]]), + "label": "Qt context access: " + metadata.get("reference", ""), "type": "concept", "file_type": "code", "_origin": "ast", + "source_file": source["source_file"], "source_location": source["source_location"], + "metadata": {"qt": encode_qt({"kind": "context_access", "span": span, "owner_id": source["id"], + "binding_id": binding["id"], "target_id": resolution.target_id, "status": "resolved", "bridge_direction": "qml_to_cpp"})}} + update_qt(site, endpoint_proof=proof, evidence=list(resolution.evidence)) + if any(value["id"] == site["id"] for value in result.get("nodes", [])): + continue + result.setdefault("nodes", []).append(site) + all_nodes.append(site) + edges = [qt_edge(source, site["id"], "contains", "qt_context_access", span=span), + qt_edge(site, resolution.target_id, "calls" if metadata.get("kind") == "call" and bridge.metadata(resolution.target_id).get("kind") == "function" else "uses", "qt_context_member", bridge_direction="qml_to_cpp", + endpoint_proof=proof, evidence=list(resolution.evidence))] + result.setdefault("edges", []).extend(edges) + all_edges.extend(edges) diff --git a/graphify/qt_context_paths.py b/graphify/qt_context_paths.py new file mode 100644 index 0000000000..22cd5b076a --- /dev/null +++ b/graphify/qt_context_paths.py @@ -0,0 +1,96 @@ +"""Typed context-property chains borrow proven native APIs one hop at a time.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qml_resolution_types import Resolution +from graphify.qt_declared_provider import DeclaredProviderIndex + + +def context_path(bridge, binding, parts): + """A finite property path is static API evidence, never QObject tree identity.""" + if not parts or len(parts) > 8: + return Resolution("unsupported", reason="context_type_chain_limit"), {} + types = DeclaredProviderIndex(bridge.nodes.values()) + if not types.binding_valid(binding): + return Resolution("unavailable", reason="context_declared_provider_provenance_unavailable"), {} + current = qt_metadata(binding).get("provider_target_id") + visited, chain = {current}, [] + resolution = bridge.context_member(binding, parts[0]) + for name in parts[1:]: + previous = bridge.nodes.get(resolution.target_id) + if previous is None or qt_metadata(previous).get("kind") != "property": + return Resolution("unavailable", reason="context_child_property_unavailable"), {} + target, evidence = types.property_type(previous) + if not target or target in visited: + return Resolution("unsupported", reason="context_child_type_unavailable_or_cycle"), {} + chain.append({"property_id": previous["id"], "class_id": current, + "target_class_id": target, "evidence": evidence}) + current = target + visited.add(current) + bridge.provider_records[binding["id"]] = {"class_id": current, + "raw_name": qt_metadata(binding).get("exposed_name"), + "evidence": tuple(qt_metadata(binding).get("evidence", [])) + tuple( + item for hop in chain for item in hop["evidence"]) + (binding["id"],)} + resolution = bridge.member(binding["id"], name) + proof = bridge.endpoint_proof(resolution.target_id, resolution.evidence) if resolution.target_id else {} + if proof: + proof["type_chain"] = chain + return resolution, proof + + +def valid_context_chain(binding, proof, nodes): + """Every consumer repeats authoritative hop validation after serialization.""" + chain = proof.get("type_chain", []) + if not isinstance(chain, list) or len(chain) > 8: + return False + current = binding.get("provider_target_id") + types = DeclaredProviderIndex(nodes) + binding_node = types.nodes.get(proof.get("provider_id")) + if binding_node is None or qt_metadata(binding_node) != binding or not types.binding_valid(binding_node): + return False + visited = {current} + for hop in chain: + if not isinstance(hop, dict) or hop.get("class_id") != current: + return False + property_node = types.nodes.get(hop.get("property_id")) + if property_node is None or qt_metadata(property_node).get("class_id") != current: + return False + target, evidence = types.property_type(property_node) + if not target or target in visited or target != hop.get("target_class_id"): + return False + if not isinstance(hop.get("evidence"), list) or not set(evidence) <= set(hop["evidence"]): + return False + current = target + visited.add(current) + return proof.get("class_id") == current + + +def valid_context_endpoint(proof, nodes): + """A serialized role label cannot turn a private/ordinary method into a signal.""" + member = nodes.get(proof.get("member_fact_id")) + target = nodes.get(proof.get("canonical_target_id")) + if member is None or target is None: + return False + md = qt_metadata(member) + types = DeclaredProviderIndex(nodes) + if not types.source_owned(member): + return False + if md.get("kind") == "property": + return proof.get("kind") == "property" and proof.get("member_fact_id") == proof.get("canonical_target_id") + roles = set(md.get("roles", [])) + kind = "signal" if "signal" in roles else "function" if roles & {"invokable", "slot"} else "" + if proof.get("notify_property_id"): + prop = types.nodes.get(proof["notify_property_id"]) + pmd = qt_metadata(prop) if prop is not None else {} + accessors = [node for node in types.nodes.values() if qt_metadata(node).get("kind") == "property_accessor" + and qt_metadata(node).get("property_id") == proof["notify_property_id"] + and qt_metadata(node).get("accessor_role") == "notify"] + if (pmd.get("kind") != "property" or pmd.get("class_id") != md.get("class_id") + or pmd.get("status") != "resolved" or pmd.get("constant") + or pmd.get("notify") != md.get("raw_name") or len(accessors) != 1 + or not types.accessor_owned(accessors[0], prop, "notify") + or qt_metadata(accessors[0]).get("generic_target_id") != proof.get("canonical_target_id")): + return False + return bool(kind and proof.get("kind") == kind and md.get("kind") == "member" + and md.get("access") == "public" and md.get("generic_target_id") == proof.get("canonical_target_id") + and target.get("_callable") is True and target.get("_callable_class") is not True) diff --git a/graphify/qt_context_subscriptions.py b/graphify/qt_context_subscriptions.py new file mode 100644 index 0000000000..db38f287ed --- /dev/null +++ b/graphify/qt_context_subscriptions.py @@ -0,0 +1,162 @@ +"""Source-owned context signal subscriptions and late implicit-parameter binding. + +Context providers are established after ordinary QML lookup. This boundary keeps +subscriptions separate from calls and corrects only freshly owned handler uses. +""" +from __future__ import annotations + +from graphify.extractors.qml_facts import encode_metadata, qml_metadata +from graphify.extractors.qt_cpp_facts import encode_qt, qt_edge, qt_id, qt_metadata +from graphify.qt_context_paths import context_path + + +def _binding_paths(source, reference, bindings, index): + md, matches = qml_metadata(source), [] + parts = reference.split(".") + if not reference or md.get("lexical_shadowed") or md.get("script_file"): + return [] + if any(qml_metadata(index.nodes[nid]).get("qualifier") == parts[0] + for nid in index.imports.get(source["source_file"], [])): + return [] + local = index.resolve_member(source["source_file"], md.get("component_key", ""), + md.get("object_scope_key", ""), parts[0]) + for binding in bindings: + provider = qt_metadata(binding) + component = index.nodes.get(provider.get("component_target_id"), {}) + if (provider.get("status") != "resolved" or component.get("source_file") != source["source_file"] + or qml_metadata(component).get("component_key") != md.get("component_key")): + continue + if local.status in {"resolved", "ambiguous", "unsupported", "dynamic"} and not ( + provider.get("initial_property") and local.target_id == provider.get("declared_property_id")): + continue + path = parts if provider.get("context_object") else parts[1:] if parts[0] == provider.get("exposed_name") else [] + if path: + matches.append((binding, path)) + return matches + + +def project_context_subscriptions(results, bridge, bindings, nodes, edges, index): + """Only exact literal callbacks and handlers in a loaded component subscribe.""" + additions, new_edges, implicit = [], [], {} + # Native Connections handlers establish legacy parameter binders before any + # nested signal.connect can resolve a provider or a callback of the same name. + for kind in ("handler", "call"): + _collect_subscriptions(results, bridge, bindings, index, kind, additions, new_edges, implicit) + if kind == "handler": + _suppress_implicit_parameters(results, nodes, edges, implicit) + nodes.extend(additions) + edges.extend(new_edges) + + +def _collect_subscriptions(results, bridge, bindings, index, kind, additions, new_edges, implicit): + """Each pass borrows run-owned source facts and appends distinct subscription sites.""" + for result in results: + if not result: + continue + for source in list(result.get("nodes", [])): + md = qml_metadata(source) + if md.get("kind") != kind: + continue + form, reference, handler = "", "", "" + if (md.get("kind") == "handler" and md.get("connections") + and not md.get("disabled_reason") and not md.get("attached_prefix")): + if md.get("connection_supplied") and md.get("connection_target"): + reference, form, handler = md["connection_target"] + "." + md.get("reference", ""), "Connections", source["id"] + elif md.get("kind") == "call" and md.get("reference", "").endswith(".connect"): + if md.get("callback_argument_count") != 1 or not md.get("callback_reference"): + continue + callback = md["callback_reference"] + if md.get("callback_lexical_shadowed"): + handler = md.get("callback_lexical_target_id") + else: + handler = index.resolve_member(source["source_file"], md.get("component_key", ""), + md.get("object_scope_key", ""), callback).target_id + if handler not in index.nodes or index.md(handler).get("kind") not in {"function", "js_function"}: + continue + reference, form = md["reference"][:-8], "signal.connect" + if not form: + continue + matches = _binding_paths(source, reference, bindings, index) + accepted = [] + for binding, path in matches: + resolved, proof = context_path(bridge, binding, path) + if form == "Connections" and proof.get("kind") != "signal" and path[-1].endswith("Changed"): + # A native property notification retains its real NOTIFY + # declaration even when the handler uses property spelling. + prop, property_proof = context_path(bridge, binding, path[:-1] + [path[-1][:-7]]) + if prop.target_id and property_proof.get("kind") == "property": + resolved = bridge.property_notify(prop.target_id, binding["id"]) + proof = bridge.endpoint_proof(resolved.target_id, resolved.evidence) if resolved.target_id else {} + if proof: + proof.update(type_chain=property_proof.get("type_chain", []), notify_property_id=prop.target_id) + if resolved.target_id and proof.get("kind") == "signal": + accepted.append((binding, resolved, proof)) + if len(accepted) != 1: + continue + binding, resolved, proof = accepted[0] + site = {"id": qt_id(source["source_file"], "context_subscription", [source["id"], binding["id"]]), + "label": "Qt context subscription: " + reference, "type": "concept", "file_type": "code", "_origin": "ast", + "source_file": source["source_file"], "source_location": source["source_location"], + "metadata": {"qt": encode_qt({"kind": "context_subscription", "span": md["span"], + "owner_id": source["id"], "binding_id": binding["id"], "status": "resolved", + "target_id": resolved.target_id, "handler_target_id": handler, "subscription_form": form, + "endpoint_proof": proof, "bridge_direction": "qml_to_cpp"})}} + if any(node["id"] == site["id"] for node in result.get("nodes", [])): + continue + additions.append(site) + result.setdefault("nodes", []).append(site) + # A separate handler endpoint avoids opposite relationships sharing + # one Graph/DiGraph pair when a Connections handler also owns the site. + endpoint = {**site, "id": qt_id(source["source_file"], "context_handler_endpoint", [site["id"], handler]), + "label": "Qt context handler endpoint", "metadata": {"qt": encode_qt({ + "kind": "context_handler_endpoint", "span": md["span"], "owner_id": site["id"], + "target_id": handler, "status": "resolved"})}} + additions.append(endpoint) + result["nodes"].append(endpoint) + owned = [qt_edge(source, site["id"], "contains", "qt_context_subscription_site", span=md["span"]), + qt_edge(site, resolved.target_id, "references", "qt_context_subscription", + endpoint_proof=proof, bridge_direction="qml_to_cpp"), + qt_edge(site, endpoint["id"], "contains", "qt_context_handler_endpoint"), + qt_edge(endpoint, handler, "references", "qt_context_handler")] + new_edges.extend(owned) + result.setdefault("edges", []).extend(owned) + if form == "Connections" and md.get("implicit_parameters") is True: + implicit[source["id"]] = set(bridge.metadata(resolved.target_id).get("parameter_names", [])) + + +def _suppress_implicit_parameters(results, nodes, edges, parameters): + """Late native binders remove an earlier same-name QML fallback edge.""" + by_id, suppressed = {node["id"]: node for node in nodes}, set() + for result in results: + if not result: + continue + for source in result.get("nodes", []): + md = qml_metadata(source) + if md.get("kind") not in {"read", "call"}: + continue + first, owner, seen = md.get("reference", "").split(".")[0], md.get("owner_id"), set() + callback = md.get("callback_reference", "").split(".")[0] + updated = {key: value for key, value in md.items() if key != "raw_values"} + changed = False + while owner in by_id and owner not in seen and len(seen) < 32: + seen.add(owner) + # Explicit JS binders already carry AST authority. A native + # implicit name must never displace a nearer local declaration. + if first in parameters.get(owner, set()) and not md.get("lexical_shadowed"): + updated.update(lexical_shadowed=True, lexical_target_id="", status="dynamic", + resolved_target_id="", reason="javascript_lexical_binding") + suppressed.add(source["id"]) + changed = True + if callback in parameters.get(owner, set()) and not md.get("callback_lexical_shadowed"): + updated.update(callback_lexical_shadowed=True, callback_lexical_target_id="") + changed = True + owner = qml_metadata(by_id[owner]).get("owner_id") + if changed: + source["metadata"]["qml"] = encode_metadata(updated) + def kept(edge): + return not (edge.get("source") in suppressed and edge.get("context", "").startswith("qml_") + and edge.get("relation") in {"uses", "calls"}) + edges[:] = [edge for edge in edges if kept(edge)] + for result in results: + if result: + result["edges"][:] = [edge for edge in result.get("edges", []) if kept(edge)] diff --git a/graphify/qt_coverage.py b/graphify/qt_coverage.py new file mode 100644 index 0000000000..148cca5934 --- /dev/null +++ b/graphify/qt_coverage.py @@ -0,0 +1,62 @@ +"""Bounded accepted-source status counts for Qt/QML reports, without source I/O.""" +from __future__ import annotations + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata + +STATUSES = ("resolved", "ambiguous", "dynamic", "unsupported", "unavailable", "error") + + +def _source_fact(node, raw): + """Version and original span establish a fact; labels never infer coverage.""" + if not isinstance(raw, dict) or type(raw.get("contract_version")) is not int or raw["contract_version"] != 1: + return False + span = raw.get("span") + return (isinstance(node.get("source_file"), str) and bool(node["source_file"]) + and isinstance(raw.get("kind"), str) and bool(raw["kind"]) + and isinstance(span, dict) and type(span.get("start_byte")) is int + and type(span.get("end_byte")) is int and 0 <= span["start_byte"] <= span["end_byte"]) + + +def source_coverage(graph): + """Count each source-owned node once per contract, keeping unknown states explicit.""" + counts = {} + for _, node in graph.nodes(data=True): + metadata = node.get("metadata") + if not isinstance(metadata, dict): + continue + for name, decode in (("qml", qml_metadata), ("qt", qt_metadata)): + if not _source_fact(node, metadata.get(name)): + continue + row = counts.setdefault(name, {**dict.fromkeys(STATUSES, 0), "other": 0, "facts": 0, "invalid_metadata": 0}) + row["facts"] += 1 + try: + status = decode(node).get("status") + except (ValueError, TypeError, UnicodeError): + status = "error" + row["invalid_metadata"] += 1 + row[status if status in STATUSES else "other"] += 1 + return counts + + +def coverage_lines(graph): + """Do not equate accepted-fact resolution with complete Qt runtime coverage.""" + counts = source_coverage(graph) + if not counts: + return [] + lines = ["", "## Qt/QML source coverage", "", + "Counts describe accepted source facts, not a percentage of supported language syntax.", "", + "| Contract | Facts | Resolved | Ambiguous | Dynamic | Unsupported | Unavailable | Error | Other / no status |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|"] + for name in ("qml", "qt"): + if name not in counts: + continue + row = counts[name] + cells = [name.upper(), row["facts"], *(row[status] for status in STATUSES), row["other"]] + lines.append("| " + " | ".join(str(cell) for cell in cells) + " |") + invalid = sum(row["invalid_metadata"] for row in counts.values()) + if invalid: + lines.append(f"- {invalid} source fact(s) have invalid semantic metadata; counted as errors.") + lines += ["", "Static resolution does not prove runtime object creation, binding values, signal delivery, ordering, or thread safety.", + "Parse and graph-write failures belong to their owning diagnostics; their absence from this graph is not proof of successful extraction."] + return lines diff --git a/graphify/qt_declared_provider.py b/graphify/qt_declared_provider.py new file mode 100644 index 0000000000..837e6d11b4 --- /dev/null +++ b/graphify/qt_declared_provider.py @@ -0,0 +1,179 @@ +"""Validate accepted declaration-type hops for a context provider's public API.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_api_shape import api_shape +from graphify.extractors.cpp_class_proof import class_fact +from graphify.extractors.cpp_constructors import _decoded +from graphify.extractors.cpp_member_identity import member_fact + + +def _api_kind(member): + shape = api_shape(qt_metadata(member).get("api_type_spelling")) + return shape[1] if shape else "" + + +class DeclaredProviderIndex: + """One run owns borrowed type evidence; no source reads or global name guesses.""" + def __init__(self, nodes): + # NetworkX stores identity in mapping keys; raw extraction stores it in + # each dictionary. Borrow data without mutating either representation. + self.nodes = ({identity: {**node, "id": identity} for identity, node in nodes.items()} + if hasattr(nodes, "items") else {node["id"]: node for node in nodes}) + self.classes, self.members, self.accessors = {}, {}, {} + for node in self.nodes.values(): + md = qt_metadata(node) + if md.get("kind") == "class" and md.get("is_definition") is True: + self.classes.setdefault(md.get("class_id"), []).append(node) + elif md.get("kind") in {"member", "api_field", "property"}: + self.members.setdefault((md.get("class_id"), md.get("raw_name")), []).append(node) + elif md.get("kind") == "property_accessor": + self.accessors.setdefault((md.get("property_id"), md.get("accessor_role")), []).append(node) + + def class_record(self, identity, *, qobject=False): + records = self.classes.get(identity, []) + if (len(records) != 1 or identity not in self.nodes + or self.nodes[identity].get("_callable_class") is not True): + return None + md = qt_metadata(records[0]) + canonical = self.nodes[identity] + fact = class_fact(canonical) + if (md.get("status") != "resolved" or md.get("api_declaration_conditional") is not False + or (qobject and not md.get("is_qobject")) or not fact or fact.get("ambiguous") + or fact.get("is_definition") is not True or md.get("owner_id") != identity + or md.get("span") != fact.get("span") + or records[0].get("source_file") != canonical.get("source_file") + or md.get("class_name") != _decoded(fact.get("qualified_name_b64"))): + return None + return records[0] + + def source_owned(self, declaration): + """Borrow exact original class/member proof; no corpus rereads or name repair.""" + md = qt_metadata(declaration) + parent = self.class_record(md.get("class_id")) + if parent is None or md.get("owner_id") != md.get("class_id") or md.get("status") != "resolved": + return False + owner, span = qt_metadata(parent), md.get("span", {}) + bounds = owner.get("span", {}) + if (not isinstance(span, dict) or any(type(span.get(key)) is not int or span[key] < 0 for key in bounds) + or declaration.get("source_file") != parent.get("source_file") + or md.get("class_name") != owner.get("class_name") + or not bounds["start_byte"] < span["start_byte"] < span["end_byte"] < bounds["end_byte"] + or not (bounds["start_row"], bounds["start_column"]) < (span["start_row"], span["start_column"]) + or not (span["end_row"], span["end_column"]) < (bounds["end_row"], bounds["end_column"]) + or declaration.get("source_location") != f"L{span['start_row'] + 1}-L{span['end_row'] + 1}"): + return False + if md.get("kind") == "member": + target = self.nodes.get(md.get("generic_target_id"), {}) + fact = member_fact(target) + return bool(fact and not fact.get("ambiguous") and fact.get("role") == "declaration" + and fact.get("span") == span and target.get("source_file") == declaration.get("source_file") + and _decoded(fact.get("owner_b64")) == md.get("class_name") + and _decoded(fact.get("member_b64")) == md.get("raw_name")) + return md.get("kind") in {"api_field", "property"} + + def accessor_owned(self, accessor, property_node, role): + """Accessor links repeat the annotation's exact owner, name, file and span.""" + md, prop = qt_metadata(accessor), qt_metadata(property_node) + return bool(self.source_owned(property_node) and md.get("kind") == "property_accessor" + and md.get("status") == "resolved" and md.get("accessor_role") == role + and md.get("owner_id") == property_node["id"] and md.get("property_id") == property_node["id"] + and isinstance(prop.get(role), str) and md.get("raw_name") == prop[role] + and md.get("span") == prop.get("span") + and accessor.get("source_file") == property_node.get("source_file") + and accessor.get("source_location") == property_node.get("source_location")) + + def binding_valid(self, binding): + """Typed bindings retain the original lexical declaration and expression proof.""" + md = qt_metadata(binding) + owner = self.nodes.get(md.get("owner_id")) + if owner is None: + return False + source = qt_metadata(owner) + if source.get("kind") != "context_exposure" or source.get("provider_type_status") is None: + return True # Initial-property and earlier direct-provider contracts. + provider = self.provider(source) + return bool(provider and qt_metadata(provider[0]).get("class_id") == md.get("provider_target_id") + and source.get("provider_declaration_id") == md.get("provider_declaration_id") + and source.get("provider_identity_status") == "resolved" + and isinstance(md.get("provider_type_evidence"), list) + and set(provider[1]) <= set(md["provider_type_evidence"])) + + def api_type(self, member): + md = qt_metadata(member) + raw, target = md.get("api_type_spelling"), md.get("api_type_target_id") + position = md.get("api_type_position") + owner = self.class_record(md.get("class_id")) + if (md.get("status") != "resolved" or md.get("api_type_status") != "resolved" + or api_shape(raw) is None or not self.source_owned(member) + or not isinstance(position, int) or isinstance(position, bool) + or owner is None or not qt_metadata(owner)["span"]["start_byte"] < position <= md.get("span", {}).get("start_byte", -1) + or self.class_record(target, qobject=True) is None): + return "" + return target + + def provider(self, metadata): + root = metadata.get("provider_root_type_id") + steps = metadata.get("provider_expression_steps") + shape = api_shape(metadata.get("provider_root_type_spelling"), allow_value=True) + address_of = metadata.get("provider_root_address_of") + if (metadata.get("provider_type_status") != "resolved" or self.class_record(root) is None + or shape is None or not isinstance(address_of, bool)): + return None + if not isinstance(steps, list) or len(steps) > 8: + return None + current, evidence, visited, current_shape = root, [root], set(), shape[1] + if address_of and (steps or current_shape == "pointer"): + return None + for step in steps: + if not isinstance(step, dict) or set(step) != {"name", "role", "operator"}: + return None + if step["operator"] != ("->" if current_shape == "pointer" else "."): + return None + if step["role"] not in {"call", "field"} or not isinstance(step["name"], str): + return None + candidates = self.members.get((current, step["name"]), []) + candidates = [node for node in candidates if qt_metadata(node).get("kind") == + ("member" if step["role"] == "call" else "api_field")] + if len(candidates) != 1 or (current, step["name"]) in visited: + return None + member, md = candidates[0], qt_metadata(candidates[0]) + if md.get("access") != "public" or (step["role"] == "call" and + (md.get("parameter_types") != [] or md.get("generic_target_id") not in self.nodes)): + return None + current = self.api_type(member) + if not current: + return None + current_shape = _api_kind(member) + visited.add((md["class_id"], step["name"])) + evidence.extend((member["id"], current)) + if not steps and not address_of and current_shape != "pointer": + return None + record = self.class_record(current, qobject=True) + return (record, list(dict.fromkeys(evidence + [record["id"]]))) if record is not None else None + + def property_type(self, property_node): + """Q_PROPERTY and its accepted accessor/member must agree on the API type.""" + md = qt_metadata(property_node) + target = self.api_type(property_node) + if not target: + return "", [] + if isinstance(md.get("read"), str): + links = self.accessors.get((property_node["id"], "read"), []) + if len(links) != 1 or not self.accessor_owned(links[0], property_node, "read"): + return "", [] + accessor = qt_metadata(links[0]).get("generic_target_id") + methods = [node for node in self.members.get((md["class_id"], md["read"]), []) + if qt_metadata(node).get("generic_target_id") == accessor] + if (len(methods) != 1 or qt_metadata(methods[0]).get("parameter_types") != [] + or self.api_type(methods[0]) != target + or _api_kind(methods[0]) != _api_kind(property_node)): + return "", [] + return target, [property_node["id"], links[0]["id"], methods[0]["id"], target] + if isinstance(md.get("member"), str): + fields = [node for node in self.members.get((md["class_id"], md["member"]), []) + if qt_metadata(node).get("kind") == "api_field"] + if (len(fields) == 1 and self.api_type(fields[0]) == target + and _api_kind(fields[0]) == _api_kind(property_node)): + return target, [property_node["id"], fields[0]["id"], target] + return "", [] diff --git a/graphify/qt_event_index.py b/graphify/qt_event_index.py new file mode 100644 index 0000000000..ce5dcce96e --- /dev/null +++ b/graphify/qt_event_index.py @@ -0,0 +1,186 @@ +"""Exact accepted Qt member lookup and deliberately bounded compatibility proof.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_mapping import normalize_type +from graphify.qml_resolution_types import Resolution, answer +import re + + +def value_type(value): + value = normalize_type(value) + if "*" in value or "&&" in value: + return value + const = bool(re.search(r"\bconst\b", value)) + value = re.sub(r"\bconst\b", "", value) + return normalize_type(value.replace("&", "") if const else value) + + +class QtEventIndex: + """Class-qualified declarations, never a repository-wide member-name table.""" + + def __init__(self, nodes): + self.nodes = {node["id"]: node for node in nodes} + self.classes, self.members, self.callables = {}, {}, {} + for node in nodes: + metadata = qt_metadata(node) + if metadata.get("kind") == "class": + # A forward declaration is source evidence, not a competing + # definition. Missing legacy completeness is also unproven. + if metadata.get("is_definition") is True: + self.classes.setdefault(metadata.get("class_name"), []).append(node) + elif metadata.get("kind") == "member": + self.members.setdefault((metadata.get("class_name"), metadata.get("raw_name")), []).append(node) + elif metadata.get("kind") == "callable": + self.callables.setdefault((node.get("source_file"), metadata.get("callable_name")), []).append(node) + + def class_name(self, name): + if (name in self.classes and len(self.classes[name]) == 1 + and qt_metadata(self.classes[name][0]).get("status") == "resolved"): + return name + return "" + + def _bases(self, name): + """Only the producer's lexical base identity can authorize traversal.""" + metadata = qt_metadata(self.classes[name][0]) + bases, raw = metadata.get("canonical_base_names"), metadata.get("bases") + if (metadata.get("canonical_base_status") != "resolved" or not isinstance(bases, list) + or not isinstance(raw, list) or len(bases) != len(raw) or len(bases) > 50 + or not all(isinstance(base, str) and base for base in bases)): + return None + return bases + + def _base_access(self, name, bases): + """Legacy or malformed facts never acquire a C++ inheritance default.""" + metadata = qt_metadata(self.classes[name][0]) + access = metadata.get("canonical_base_access") + if (metadata.get("canonical_base_access_status") != "resolved" or not isinstance(access, list) + or len(access) != len(bases) or not all(isinstance(value, str) + and value in {"public", "protected", "private"} for value in access)): + return None + return access + + def _lookup_members(self, declared, name, *, public_only=False): + """Stop each branch at its first declaration before filtering its role. + + A hidden or incompatible declaration shadows ancestors just as a signal + does. Traversal owns one bounded run-local memo; failed or missing base + proof cannot select a convenient declaration from another branch. + """ + memo, visited = {}, set() + + def visit(owner, path): + owner = self.class_name(owner) + if not owner: + return [], "inheritance_identity_unavailable" + if owner in path: + return [], "inheritance_cycle_or_limit" + if owner in memo: + return memo[owner] + if len(visited) >= 32: + return [], "inheritance_cycle_or_limit" + visited.add(owner) + candidates = list(self.members.get((owner, name), [])) + if candidates: + memo[owner] = candidates, "" + return memo[owner] + bases = self._bases(owner) + if bases is None: + return [], "inheritance_identity_unavailable" + access = self._base_access(owner, bases) if public_only else ["public"] * len(bases) + if access is None: + return [], "inheritance_access_unavailable" + for base, visibility in zip(bases, access): + inherited, reason = visit(base, path | {owner}) + if reason: + return [], reason + if public_only and inherited and visibility != "public": + return [], "inheritance_access_unavailable" + candidates.extend(inherited) + memo[owner] = candidates, "" + return memo[owner] + + return visit(declared, frozenset()) + + def inherits(self, actual, declared, seen=None, *, public_only=False): + actual, declared = self.class_name(actual), self.class_name(declared) + if not actual or not declared: + return False + if actual == declared: + return True + seen = set() if seen is None else seen + if actual in seen or len(seen) >= 32: + return False + seen.add(actual) + bases = self._bases(actual) + if bases is None: + return False + access = self._base_access(actual, bases) if public_only else ["public"] * len(bases) + return bool(access is not None and any(self.inherits(base, declared, seen, public_only=public_only) + for base, visibility in zip(bases, access) if visibility == "public")) + + def member(self, endpoint, actual_type, *, role=None, owner_class=""): + if endpoint.get("form") not in {"legacy", "member_pointer"}: + return Resolution("dynamic", reason="dynamic_endpoint") + actual = self.class_name(actual_type) + declared = self.class_name(endpoint.get("class_name") or actual_type) + if not actual or not declared: + return Resolution("unavailable", reason="unestablished_object_type") + public_only = endpoint.get("form") == "member_pointer" + if not self.inherits(actual, declared): + return Resolution("unavailable", reason="object_member_type_mismatch") + if public_only and not self.inherits(actual, declared, public_only=True): + return Resolution("unavailable", reason="inheritance_access_unavailable") + candidates, reason = self._lookup_members(declared, endpoint.get("member_name"), public_only=public_only) + if reason: + return Resolution("unavailable", reason=reason) + grouped = {} + for node in candidates: + metadata = qt_metadata(node) + key = (metadata.get("generic_target_id") or node["id"], metadata.get("signature")) + grouped.setdefault(key, []).append(node) + candidates = [min(group, key=lambda node: (qt_metadata(node).get("access") == "public", not bool(qt_metadata(node).get("roles")), node["id"])) for group in grouped.values()] + # Distinct declaring classes are ambiguous even if a selector or role + # happens to match only one branch. Repeated diamond paths to the exact + # same canonical declaration have already deduplicated above. + if len({qt_metadata(node).get("class_name") for node in candidates}) > 1: + return answer([node["id"] for node in candidates], evidence=("source_type",)) + candidates = [node for node in candidates if qt_metadata(node).get("status") == "resolved"] + if role: + candidates = [node for node in candidates if role in qt_metadata(node).get("roles", [])] + if endpoint.get("selector"): + normalizer = value_type if endpoint.get("form") == "legacy" else normalize_type + wanted = [normalizer(value) for value in endpoint.get("parameter_types", [])] + candidates = [node for node in candidates if [normalizer(value) for value in qt_metadata(node).get("parameter_types", [])] == wanted] + if endpoint.get("form") == "member_pointer": + candidates = [node for node in candidates if qt_metadata(node).get("access") == "public" + or qt_metadata(node).get("class_name") == owner_class] + return answer([node["id"] for node in candidates], reason="member_not_proven", evidence=("source_type", "declared_signature")) + + def emission(self, metadata): + expression = {"form": "legacy", "member_name": metadata.get("member_name"), "selector": False} + result = self.member(expression, metadata.get("receiver_type") or "", role="signal") + if result.status == "resolved": + parameters = qt_metadata(self.nodes[result.target_id]).get("parameter_types", []) + if len(parameters) != metadata.get("argument_count"): + return Resolution("unavailable", reason="signal_argument_count_mismatch") + return result + + def compatible(self, signal_id, receiver_id=None, parameters=None): + offered = qt_metadata(self.nodes[signal_id]).get("parameter_types", []) + required = parameters if parameters is not None else qt_metadata(self.nodes[receiver_id]).get("parameter_types", []) + if len(required) > len(offered): + return False + # This profile proves exact normalized types only. Compiler conversions, + # typedefs, and templated callable deduction stay explicitly unsupported. + return all(value_type(left) == value_type(right) for left, right in zip(offered, required)) + + def callable(self, site): + metadata = qt_metadata(site) + if metadata.get("callable_form") == "functor": + declared = self.class_name(metadata.get("callable_type", "")) + candidates = self.members.get((declared, "operator()"), []) + candidates = [node for node in candidates if qt_metadata(node).get("access") == "public"] + else: + candidates = self.callables.get((site.get("source_file"), metadata.get("callable_reference")), []) + return answer([node["id"] for node in candidates], reason="callable_signature_unestablished", evidence=("source_callable",)) diff --git a/graphify/qt_event_resolution.py b/graphify/qt_event_resolution.py new file mode 100644 index 0000000000..7c999c6308 --- /dev/null +++ b/graphify/qt_event_resolution.py @@ -0,0 +1,114 @@ +"""Project Qt emissions and connection endpoints without propagation calls.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import encode_qt, qt_edge, qt_id, qt_metadata, scope_owner, update_qt +from graphify.qt_event_index import QtEventIndex +from graphify.qml_resolution_types import Resolution + + +def _record(site, prefix, result): + update_qt(site, **{prefix + "_status": result.status, prefix + "_target_id": result.target_id or "", + prefix + "_reason": result.reason, prefix + "_candidates": list(result.candidates)}) + + +def _role(site, target, role, result, additions, edges): + """Separate role facts retain signal-to-signal and repeated occurrence edges.""" + metadata = qt_metadata(site) + node = {"id": qt_id(site["source_file"], "event_endpoint", [site["id"], role]), + "label": "Qt connection " + role, "type": "concept", "file_type": "code", "_origin": "ast", + "source_file": site["source_file"], "source_location": site["source_location"], + "metadata": {"qt": encode_qt({"kind": "event_endpoint", "role": role, "owner_id": site["id"], + "span": metadata["span"], "target_id": target, + "status": result.status, "bridge_direction": "cpp_to_cpp"})}} + additions.append(node) + # Endpoint role ownership follows the observed connection syntax. The + # independently resolved declaration edge below remains inferred. + ownership = qt_edge(site, node["id"], "contains", "qt_connection_endpoint", role=role) + ownership["confidence"] = "EXTRACTED" + edges.append(ownership) + edges.append(qt_edge(node, target, "references", "qt_" + site.get("metadata", {}).get("qt", {}).get("kind", "connect") + "_" + role, + role=role, bridge_direction="cpp_to_cpp")) + + +def resolve_qt_events(per_file, all_nodes, all_edges, *, root, project_index=None): + """Mutate only fresh source-owned sites; borrowed accepted corpus stays read-only.""" + del root, project_index + index = QtEventIndex(all_nodes) + connection_handles = {} + results = per_file.values() if isinstance(per_file, dict) else per_file + for result in results: + if not result: + continue + additions, projected = [], [] + for site in list(result.get("nodes", [])): + metadata = qt_metadata(site) + kind = metadata.get("kind") + if kind == "emission": + resolution = index.emission(metadata) + update_qt(site, status=resolution.status, target_id=resolution.target_id or "", reason=resolution.reason, + candidates=list(resolution.candidates), bridge_direction="cpp_to_cpp") + if resolution.target_id: + projected.append(qt_edge(site, resolution.target_id, "uses", "qt_signal_emit", bridge_direction="cpp_to_cpp")) + continue + if kind not in {"connect", "disconnect"}: + continue + if kind == "disconnect" and metadata.get("handle_reference"): + handle = connection_handles.get((scope_owner(metadata), metadata["handle_reference"])) + if handle and handle[1] == metadata.get("handle_assignment_byte") and not metadata.get("conditional"): + update_qt(site, status="resolved", target_id=handle[0], reason="", bridge_direction="cpp_to_cpp") + projected.append(qt_edge(site, handle[0], "uses", "qt_disconnect", bridge_direction="cpp_to_cpp")) + else: + update_qt(site, status="dynamic", reason="connection_handle_unestablished") + continue + signal = index.member(metadata.get("signal", {}), metadata.get("sender_type", ""), role="signal", + owner_class=metadata.get("owner_class", "")) + _record(site, "signal", signal) + receiver_id, reason = "", "" + if signal.target_id: + _role(site, signal.target_id, "signal", signal, additions, projected) + form = metadata.get("callable_form") + if form in {"member_pointer", "legacy"}: + endpoint = metadata.get("receiver", {}) + receiver = index.member(endpoint, metadata.get("receiver_type", ""), + role=endpoint.get("role") or None, owner_class=metadata.get("owner_class", "")) + _record(site, "receiver", receiver) + if receiver.target_id and signal.target_id and index.compatible(signal.target_id, receiver.target_id): + receiver_id = receiver.target_id + _role(site, receiver_id, "receiver", receiver, additions, projected) + else: + reason = receiver.reason or "incompatible_or_unestablished_signature" + elif form == "lambda" and metadata.get("lambda_id"): + receiver_id = metadata["lambda_id"] + parameters = qt_metadata(index.nodes.get(receiver_id, {})).get("parameter_types", []) + if not signal.target_id or not index.compatible(signal.target_id, parameters=parameters): + receiver_id, reason = "", "incompatible_or_unestablished_lambda_signature" + else: + _role(site, receiver_id, "receiver", Resolution("resolved", receiver_id), additions, projected) + elif form in {"functor", "function"}: + callable_result = index.callable(site) + _record(site, "receiver", callable_result) + if callable_result.target_id and signal.target_id and index.compatible(signal.target_id, callable_result.target_id): + receiver_id = callable_result.target_id + _role(site, receiver_id, "receiver", callable_result, additions, projected) + else: + reason = callable_result.reason or "incompatible_callable_signature" + else: + reason = "dynamic_callable" + status = "resolved" if signal.target_id and receiver_id else "ambiguous" if signal.status == "ambiguous" else "dynamic" if form == "dynamic" else "unavailable" + if kind == "disconnect" and metadata.get("signal_wildcard") and metadata.get("callable_wildcard"): + status = "resolved" if index.class_name(metadata.get("sender_type", "")) else "unavailable" + reason = "source_wildcard_disconnect" if status == "resolved" else "sender_type_unestablished" + update_qt(site, status=status, reason=reason or signal.reason, receiver_target_id=receiver_id, + bridge_direction="cpp_to_cpp", unique_connection_effect="not_guaranteed_for_functors" if form in {"lambda", "functor", "function"} else "unverified") + if kind == "connect" and status == "resolved" and metadata.get("assigned_handle") and not metadata.get("conditional"): + connection_handles[(scope_owner(metadata), metadata["assigned_handle"])] = (site["id"], metadata.get("assignment_byte")) + existing = {node["id"] for node in result.get("nodes", [])} + fresh = [node for node in additions if node["id"] not in existing] + result.setdefault("nodes", []).extend(fresh) + if all_nodes is not result["nodes"]: + all_nodes.extend(node for node in fresh if node["id"] not in index.nodes) + known = {(edge["source"], edge["target"], edge.get("context")) for edge in result.get("edges", [])} + new_edges = [edge for edge in projected if (edge["source"], edge["target"], edge.get("context")) not in known] + result.setdefault("edges", []).extend(new_edges) + if all_edges is not result["edges"]: + all_edges.extend(new_edges) diff --git a/graphify/qt_export.py b/graphify/qt_export.py new file mode 100644 index 0000000000..3078ef6f02 --- /dev/null +++ b/graphify/qt_export.py @@ -0,0 +1,118 @@ +"""Qt/QML graph-database payloads retain literal facts and logical direction.""" +from __future__ import annotations + +import hashlib +import json +import re + +from graphify.graph_direction import logical_endpoints as accepted_endpoints + + +def has_qt_qml(graph) -> bool: + return any(_typed(data) for _, data in graph.nodes(data=True)) or any( + _typed(data) for _, _, data in graph.edges(data=True)) + + +def _typed(data) -> bool: + metadata = data.get("metadata") + if not isinstance(metadata, dict): + return False + return any(isinstance(value := metadata.get(name), dict) + and type(value.get("contract_version")) is int and value["contract_version"] == 1 + for name in ("qml", "qt")) + + +def export_properties(data, *, preserve_metadata=False) -> dict: + """Database properties are scalar; nested semantic payloads use exact JSON. + + The compatibility path preserves the existing scalar-only property view. + Qt/QML graphs retain metadata/attributes as separately named JSON strings, + including canonical endpoint evidence and lossless literal transport. + """ + if preserve_metadata: + _reserved(data, {"metadata_json", "attributes_json"}) + result = {key: value for key, value in data.items() if isinstance(key, str) + and not key.startswith("_") and isinstance(value, (str, int, float, bool))} + if preserve_metadata: + for key in ("metadata", "attributes"): + if isinstance(data.get(key), (dict, list)): + result[key + "_json"] = _json(data[key]) + return result + + +def logical_endpoints(source, target, data, *, directed=False): + """Exports share consumer pair validation while retaining their stable rejection code.""" + endpoints = accepted_endpoints(source, target, data, directed=directed) + if endpoints is None: + raise ValueError("QT_EXPORT_DIRECTION: invalid logical edge pair; re-extract or repair the graph") + return endpoints + + +def edge_properties(source, target, data, *, preserve_metadata=False) -> dict: + result = export_properties(data, preserve_metadata=preserve_metadata) + if preserve_metadata: + _reserved(data, {"graphify_source", "graphify_target", "graphify_fact_key"}) + result["graphify_source"], result["graphify_target"] = source, target + result["graphify_fact_key"] = hashlib.sha256(_json([source, target, result]).encode()).hexdigest() + return result + + +def _reserved(data, keys): + if any(key in data for key in keys): + raise ValueError("QT_EXPORT_PROPERTY_COLLISION: reserved transport property already exists") + + +def preflight_qt_payload(graph) -> None: + """Validate the complete Qt transport before connecting or writing externally.""" + for _, data in graph.nodes(data=True): + export_properties(data, preserve_metadata=True) + for source, target, data in graph.edges(data=True): + # External exports honor complete logical pairs on bidirectional storage + # wrappers. JSON separately checks native directed identity when persisted. + src, tgt = logical_endpoints(source, target, data) + edge_properties(src, tgt, data, preserve_metadata=True) + + +def _json(value) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + + +def _identifier(value, fallback): + name = re.sub(r"[^A-Za-z0-9_]", "_", str(value)) or fallback + return name if not name[0].isdigit() else fallback + "_" + name + + +def _map(values): + # Escaped identifier keys and non-interpolated JSON string literals prevent + # property data from becoming executable Cypher. Live pushes use parameters. + entries = [] + for key, value in sorted(values.items()): + quoted_key = "`" + key.replace("`", "``") + "`" + entries.append(quoted_key + ":" + _json(value)) + return "{" + ",".join(entries) + "}" + + +def qt_cypher_lines(graph) -> list[str] | None: + """Return a Qt-aware complete upsert script, or the untouched legacy path. + + Source facts remain inspectable JSON properties. Stable relationship keys + preserve distinct mechanisms even for parallel accepted endpoint pairs. + This writer does not execute a database or remove earlier database records. + """ + if not has_qt_qml(graph): + return None + lines = ["// Qt/QML Cypher transport: metadata_json retains literal source facts.", + "// Upsert only; stale database records need an explicit replacement policy.", ""] + for node_id, data in graph.nodes(data=True): + props = export_properties(data, preserve_metadata=True) + props["id"] = node_id + label = _identifier(str(data.get("file_type") or "Entity").capitalize(), "Entity") + lines.append(f"MERGE (n:{label} {{id:{_json(node_id)}}}) SET n += {_map(props)};") + for source, target, data in graph.edges(data=True): + src, tgt = logical_endpoints(source, target, data) + props = edge_properties(src, tgt, data, preserve_metadata=True) + relation = _identifier(str(data.get("relation") or "RELATES_TO").upper(), "RELATES_TO") + key = _json(props["graphify_fact_key"]) + lines.append(f"MATCH (a {{id:{_json(src)}}}), (b {{id:{_json(tgt)}}}) " + f"MERGE (a)-[r:{relation} {{graphify_fact_key:{key}}}]->(b) SET r += {_map(props)};") + return lines diff --git a/graphify/qt_generated_conflicts.py b/graphify/qt_generated_conflicts.py new file mode 100644 index 0000000000..4eb2fa54eb --- /dev/null +++ b/graphify/qt_generated_conflicts.py @@ -0,0 +1,41 @@ +"""Report generated tooling/source disagreements while preserving both origins.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qml_resolution_types import qml_metadata + + +def generated_conflicts(index) -> list[dict]: + diagnostics = [] + native_members = {} + for node in index.nodes.values(): + md = qt_metadata(node) + if md.get("kind") in {"property", "member"} and md.get("class_name"): + native_members.setdefault((md["class_name"], md.get("raw_name")), []).append(node) + + def add(generated, source_id, reason): + if len(diagnostics) < 50: + md = qml_metadata(generated) + diagnostics.append({"code": "QML_TYPES_CONFLICT", "severity": "warning", + "owner": "qt_project_index", "source_file": generated["source_file"], + "span": md.get("span"), "reason": reason, + "generated_id": generated["id"], "source_id": source_id, + "message": "Both generated and source declarations are retained", + "recovery": "Regenerate tooling metadata or reconcile the source declarations."}) + + for node in index.nodes.values(): + md = qml_metadata(node) + if md.get("kind") == "qmltypes_export": + source = index.resolve_component(md["uri"], md["raw_name"], md.get("major"), md.get("minor")) + if source.target_id or source.status == "ambiguous": + add(node, source.target_id, "generated_and_source_type_origins") + elif md.get("kind") == "qmltypes_member": + for source in native_members.get((md.get("cpp_name"), md.get("raw_name")), []): + native = qt_metadata(source) + pairs = [(md.get("raw_type"), native.get("raw_type") if native.get("kind") == "property" + else native.get("return_type"))] + if native.get("kind") == "property": + pairs.append((md.get("notify"), native.get("notify"))) + if any(a and b and a != b for a, b in pairs): + add(node, source["id"], "generated_member_disagrees_with_source") + return diagnostics diff --git a/graphify/qt_html.py b/graphify/qt_html.py new file mode 100644 index 0000000000..7417383486 --- /dev/null +++ b/graphify/qt_html.py @@ -0,0 +1,45 @@ +"""Qt/QML HTML fields borrow complete contracts and project readable semantics.""" +from __future__ import annotations + +import json + +from graphify.qt_qml_search import SEARCH_NAMESPACE, search_attributes +from graphify.qt_relationship_views import relationship_label +from graphify.security import sanitize_label + + +def semantic_fields(data): + metadata = data.get("metadata") + if not isinstance(metadata, dict) or not any(isinstance(metadata.get(name), dict) + and type(metadata[name].get("contract_version")) is int + and metadata[name]["contract_version"] == 1 for name in ("qml", "qt")): + return {} + # The encoder reads these dictionaries only. Existing attributes, including + # an occupied public search namespace, retain their authoritative values. + attributes = search_attributes(data) + fields = {"metadata": metadata, "attributes": attributes, + "source_location": str(data.get("source_location") or "")} + original = data.get("attributes") + if SEARCH_NAMESPACE in attributes and not (isinstance(original, dict) and SEARCH_NAMESPACE in original): + fields["qt_qml"] = attributes[SEARCH_NAMESPACE] + return fields + + +def semantic_title(fields): + value = fields.get("qt_qml") + return "\n" + sanitize_label(json.dumps(value, ensure_ascii=False)) if value else "" + + +def edge_fields(data): + fields = semantic_fields(data) + if fields: + fields.update(context=data.get("context", ""), source_file=data.get("source_file", "")) + return fields + + +def edge_title(data): + label = relationship_label(data) + if not label: + return "" + source = str(data.get("source_file") or "") + ":" + str(data.get("source_location") or "") + return sanitize_label(" — " + label + " at " + source) diff --git a/graphify/qt_incremental.py b/graphify/qt_incremental.py new file mode 100644 index 0000000000..0efda81d42 --- /dev/null +++ b/graphify/qt_incremental.py @@ -0,0 +1,115 @@ +"""Pure Qt input/cache/update policy, over caller-owned accepted corpus snapshots.""" +from __future__ import annotations + +import hashlib +import json +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path + +QT_METADATA_SUFFIXES = frozenset({".qmltypes", ".cmake", ".pro", ".pri", ".qrc"}) +QT_CPP_SUFFIXES = frozenset({".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".hxx"}) +QT_SCRIPT_SUFFIXES = frozenset({".js", ".mjs", ".cjs"}) +QT_NAMED_METADATA = frozenset({"qmldir", "CMakeLists.txt"}) +# Followed Qt metadata now retains its walked source owner. Refresh unchanged +# prior products through the existing publication owner rather than reusing +# canonical-only metadata facts; the syntax cache has its separate epoch. +QT_POLICY_VERSION = 22 + + +def is_qt_metadata(path: str | Path) -> bool: + p = Path(path) + return p.name in QT_NAMED_METADATA or p.suffix.lower() in QT_METADATA_SUFFIXES + + +def is_qt_source(path: str | Path) -> bool: + return Path(path).suffix.lower() == ".qml" + + +def qt_syntax_cache_bypass(path: str | Path, *, native=False) -> bool: + """Qt source/metadata stays uncached; native syntax bypass is explicit.""" + return is_qt_source(path) or is_qt_metadata(path) or (native and Path(path).suffix.lower() in QT_CPP_SUFFIXES) + + +def requires_native_refresh(paths: Iterable[str | Path]) -> bool: + """Inspect only accepted bounded inputs; comments may conservatively refresh. + + A Qt corpus must reparse canonical native declarations when its installed + parser changes. Plain C++ without Qt/recovery candidates retains its cache. + """ + import re + paths = tuple(Path(path) for path in paths) + if any(is_qt_source(path) or is_qt_metadata(path) for path in paths): + return True + # Same-version fork upgrades can change valid default/Q_UNUSED syntax. + # Comments adjacent to a possible empty default conservatively refresh; + # only the parser's lexical/AST adapter determines actual source semantics. + markers = re.compile(rb"Q_OBJECT|Q_GADGET|Q_PROPERTY|Q_INVOKABLE|QML_|qmlRegister|QQml|QQuickView|QObject|Q_SIGNALS|Q_SLOTS|Q_SIGNAL|Q_SLOT|Q_EMIT|SIGNAL\s*\(|SLOT\s*\(|Q_UNUSED\b|=\s*(?:\{\s*(?:\}|/\*|//)|/\*|//)") + for path in paths: + if path.suffix.lower() not in QT_CPP_SUFFIXES: + continue + try: + with path.open("rb") as stream: + raw = stream.read(5_000_001) + except OSError: + return True + if len(raw) > 5_000_000 or markers.search(raw): + return True + return False + + +@dataclass(frozen=True) +class QtRefreshPlan: + required: bool + reasons: tuple[str, ...] + accepted_inputs: tuple[str, ...] + + +def plan_qt_refresh(corpus_paths: Iterable[str | Path], changed_paths: Iterable[str | Path], *, + prior_paths: Iterable[str | Path] = (), configuration_changed=False, + qt_facts_present=False) -> QtRefreshPlan: + """A conservative plan rebuilds only inputs already admitted by the caller. + + Prior paths establish context after a last-provider deletion; they never + authorize re-reading that provider. No glob, directory walk or stat runs. + C++ changes can introduce Qt semantics, so C++ corpora conservatively refresh + rather than use a stale native-event index when a changed source gains Qt. + """ + corpus = tuple(str(path) for path in corpus_paths) + changed = tuple(str(path) for path in changed_paths) + prior = tuple(str(path) for path in prior_paths) + has_qt = qt_facts_present or any(is_qt_source(path) or is_qt_metadata(path) + or Path(path).suffix.lower() in QT_CPP_SUFFIXES + for path in (*corpus, *prior)) + reasons = set() + if configuration_changed and has_qt: + reasons.add("qt_analysis_configuration_changed") + for path in changed: + suffix = Path(path).suffix.lower() + if is_qt_source(path) or is_qt_metadata(path): + reasons.add("qt_source_or_metadata_changed") + elif suffix in QT_CPP_SUFFIXES: + reasons.add("qt_native_provider_changed") + elif has_qt and suffix in QT_SCRIPT_SUFFIXES: + reasons.add("qt_script_provider_changed") + elif has_qt and Path(path).name in {".graphifyignore", ".gitignore"}: + reasons.add("qt_corpus_policy_changed") + return QtRefreshPlan(bool(reasons), tuple(sorted(reasons)), tuple(sorted(set(corpus)))) + + +def qt_analysis_fingerprint(*, parser_version: str, fact_version: int, + import_roots: Iterable[str] = (".",), + ignore_patterns: Iterable[str] = (), profile="qt6-static") -> str: + """A caller-persisted stamp covers semantic inputs beyond per-file contents.""" + roots, ignores = tuple(import_roots), tuple(ignore_patterns) + if len(roots) > 256 or len(ignores) > 10_000: + raise ValueError("QT_CONFIG_LIMIT: analysis input count exceeds supported bounds") + values = (*roots, *ignores, parser_version, profile) + if any(not isinstance(value, str) or len(value) > 4096 for value in values): + raise ValueError("QT_CONFIG_LIMIT: malformed analysis configuration") + if not isinstance(fact_version, int) or fact_version < 1: + raise ValueError("QT_CONFIG: invalid fact contract version") + payload = {"policy_version": QT_POLICY_VERSION, "parser_version": parser_version, + "fact_version": fact_version, "import_roots": roots, + "ignore_patterns": ignores, "profile": profile} + return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode()).hexdigest() diff --git a/graphify/qt_orphan_cleanup.py b/graphify/qt_orphan_cleanup.py new file mode 100644 index 0000000000..533a3d9ff1 --- /dev/null +++ b/graphify/qt_orphan_cleanup.py @@ -0,0 +1,89 @@ +"""Retire stale generic AST placeholders after a complete accepted Qt refresh. + +The caller owns discovery, extraction, reconciliation and publication. This pure +step never expands the corpus or mutates a borrowed graph dictionary. It removes +only unreferenced placeholders omitted by the successful fresh extraction. +""" +from __future__ import annotations + +from graphify.graph_direction import logical_endpoints + + +def previous_import_targets(prior: dict | None, *, import_relations: frozenset[str]) -> set[str]: + """Derive exact external candidates from accepted prior source-owned AST imports. + + The caller supplies the builder's canonical import family. Duplicate IDs, + missing owners and corrupted logical pairs cannot authorize cleanup; labels + and sourceless semantic edges are never source ownership evidence. + """ + if not isinstance(prior, dict): + return set() + nodes, edges = prior.get("nodes", []), prior.get("links", prior.get("edges", [])) + if not isinstance(nodes, list) or not isinstance(edges, list): + return set() + owners, duplicates = {}, set() + for node in nodes: + if not isinstance(node, dict) or not isinstance(node.get("id"), str): + continue + identity = node["id"] + if identity in owners: + duplicates.add(identity) + else: + owners[identity] = node + targets = set() + for edge in edges: + if (not isinstance(edge, dict) or edge.get("_origin") != "ast" + or not isinstance(edge.get("relation"), str) or edge["relation"] not in import_relations + or edge.get("context") != "import" + or edge.get("confidence") != "EXTRACTED"): + continue + pair = logical_endpoints(edge.get("source"), edge.get("target"), edge, + directed=bool(prior.get("directed", False))) + if pair is None or not all(isinstance(value, str) for value in pair): + continue + source, target = pair + if source in duplicates or target in duplicates or target not in owners: + continue + owner, source_file = owners.get(source, {}), edge.get("source_file") + if (isinstance(source_file, str) and source_file and owner.get("source_file") == source_file + and owner.get("_origin") == "ast" and owner.get("file_type") == "code"): + targets.add(target) + return targets + + +def prune_stale_ast_orphans(result: dict, *, fresh_ids: set[str], complete_refresh: bool, + prior_import_ids: set[str] | None = None) -> dict: + """Return a filtered view; partial refreshes and authoritative facts survive. + + Empty source provenance alone is insufficient. A generic AST placeholder or + source-owned prior import endpoint must also be absent from fresh output and + every surviving ordinary/hyperedge reference. Borrowed inputs stay unchanged. + """ + if not complete_refresh: + return result + external_ids = prior_import_ids or set() + referenced = set() + for edge in result.get("edges", []): + referenced.update((edge.get("source"), edge.get("target"))) + for edge in result.get("hyperedges", []): + members = edge.get("nodes", edge.get("members", edge.get("node_ids", []))) + if isinstance(members, list): + referenced.update(value for value in members if isinstance(value, str)) + + def stale(node): + generic_ast = (node.get("_origin") == "ast" and node.get("file_type") == "code" + and not node.get("type")) + # The builder-created external shape acquires semantic origin on reload. + # Exact prior AST import identity supplies its omitted ownership proof; + # an arbitrary same-label semantic/user node cannot authorize removal. + generic_external = (node.get("id") in external_ids + and node.get("file_type") == "concept" and node.get("type") == "external" + and node.get("external") is True and node.get("label") == node.get("id") + and node.get("_origin") in (None, "semantic") and "metadata" not in node) + return (node.get("id") not in fresh_ids and node.get("id") not in referenced + and (generic_ast or generic_external) and not node.get("source_file") + and not node.get("source_location") and not node.get("metadata") + and not any(key.startswith("_callable") for key in node)) + + retained = [node for node in result.get("nodes", []) if not stale(node)] + return {**result, "nodes": retained} diff --git a/graphify/qt_project_index.py b/graphify/qt_project_index.py new file mode 100644 index 0000000000..59a14d134e --- /dev/null +++ b/graphify/qt_project_index.py @@ -0,0 +1,145 @@ +"""Per-run literal Qt build/module/resource index over the accepted corpus only.""" +from __future__ import annotations + +import posixpath +from pathlib import Path + +from graphify.extractors.qml_project_read import literal_path +from graphify.extractors.qml_resources import resource_url +from graphify.qml_module_index import QmlModuleIndex +from graphify.qml_resolution_types import Resolution, answer, qml_metadata, source_path +from graphify.qt_generated_conflicts import generated_conflicts +from graphify.qt_resource_index import QtResourceIndex, normalize_url + + +class QtProjectIndex: + """Borrow all source dictionaries read-only; derived lookup records stay local.""" + def __init__(self, nodes, edges, *, root: Path, import_roots=None): + self.root = Path(root).resolve() + self.nodes, self.paths, self.modules, self.sources = {}, {}, {}, {} + for node in nodes: + path = source_path(node, self.root) + if path is not None and node.get("id"): + self.nodes[node["id"]], self.paths[node["id"]] = node, path + md = qml_metadata(node) + if md.get("kind") == "qt_module": + self.modules.setdefault(md.get("module_key"), []).append(node["id"]) + elif md.get("kind") == "qt_source": + self.sources.setdefault(md.get("module_key"), []).append(node["id"]) + self.module_index = QmlModuleIndex(nodes, edges, root=self.root, import_roots=import_roots) + self.resource_index = QtResourceIndex(nodes, root=self.root) + self.memberships, self.qml_files = {}, {} + for key, records in self.sources.items(): + for nid in records: + md = qml_metadata(self.nodes[nid]) + path = literal_path(self.paths[nid], md.get("value", "")) + if path is None or path not in self.module_index.by_file: + continue + self.memberships.setdefault(path, []).extend(self.modules.get(key, [])) + if md.get("source_kind") == "qml": + self.qml_files.setdefault(key, []).append((path, nid)) + elif md.get("source_kind") == "qrc": + for aid in self.module_index.by_file[path]: + alias = qml_metadata(self.nodes[aid]) + if alias.get("kind") == "resource_alias": + target = alias.get("target_path") + if target in self.module_index.components: + self.qml_files.setdefault(key, []).append((target, aid)) + self.diagnostics = generated_conflicts(self) + + def module_metadata(self, module_id: str) -> dict: + return dict(qml_metadata(self.nodes[module_id])) + + def module_context(self, source_file: str) -> Resolution: + normalized = source_path({"source_file": source_file}, self.root) + ids = self.memberships.get(normalized, []) + result = answer(ids, reason="source_module_context_unavailable", evidence=ids) + return Resolution(result.status, result.target_id, result.reason, + tuple(sorted(set(ids))[:50]), result.candidates) + + def _eligible(self, md, major, minor): + if major is None: + return True + if major != md.get("major"): + return False + return minor is None or (isinstance(md.get("minor"), int) and minor <= md["minor"]) + + def module_import(self, uri: str, major=None, minor=None) -> Resolution: + """Build-declared namespaces require exact URI/version/provider evidence.""" + providers = [] + for ids in self.modules.values(): + for mid in ids: + md = qml_metadata(self.nodes[mid]) + if md.get("uri") == uri and self._eligible(md, major, minor): + providers.append(mid) + # A common CMake target spelling does not prove two declarations share + # build provenance. Only the exact source-owned identity may coalesce. + evidence = tuple(sorted(set(providers))[:50]) + if len(set(providers)) > 1: + return Resolution("ambiguous", reason="duplicate_module_provider", + candidates=evidence, + evidence=evidence) + if providers: + return Resolution("resolved", evidence[0], evidence=evidence) + return Resolution("unavailable", reason="module_or_version_unavailable") + + def module_type(self, uri, major, minor, name, *, importer="", **kwargs) -> Resolution: + """Match the established QML index seam without adding runtime visibility.""" + return self.resolve_component(uri, name, major, minor) + + def resolve_component(self, uri: str, type_name: str, major=None, minor=None) -> Resolution: + """Resolve declared module QML sources, plus established qmldir visibility.""" + namespace = self.module_import(uri, major, minor) + if namespace.status == "ambiguous": + return namespace + candidates, evidence, providers = [], [], [] + for key, ids in self.modules.items(): + for mid in ids: + md = qml_metadata(self.nodes[mid]) + if md.get("uri") != uri: + continue + providers.append(mid) + if not self._eligible(md, major, minor): + continue + for path, sid in self.qml_files.get(key, []): + name = posixpath.basename(path).removesuffix(".ui.qml").removesuffix(".qml") + if name == type_name and name[:1].isupper(): + candidates.extend(self.module_index.components.get(path, [])) + evidence.extend((mid, sid)) + declared = answer(candidates, reason="module_type_unavailable", evidence=evidence) + existing = self.module_index.module_type(uri, major, minor, type_name, importer="") + if existing.status in {"ambiguous", "unsupported"}: + return existing + if existing.target_id: + return answer(candidates + [existing.target_id], evidence=evidence + list(existing.evidence)) + if declared.target_id or declared.status == "ambiguous": + return declared + if providers: + return Resolution("unavailable", reason="module_type_or_version_unavailable", evidence=tuple(providers[:50])) + return existing + + def resolve_url(self, literal_url: str, base_url: str | None = None) -> Resolution: + # CMake-generated aliases exist only when the prefix is explicit; policy + # defaults and source-property aliases are never guessed. + result = self.resource_index.resolve_url(literal_url, base_url) + if result.reason != "resource_alias_unavailable": + return result + logical = normalize_url(literal_url, base_url) + if isinstance(logical, Resolution): + return logical + matches, evidence = [], [] + for key, ids in self.modules.items(): + for mid in ids: + md = qml_metadata(self.nodes[mid]) + prefix = md.get("resource_prefix") + if not isinstance(prefix, str): + continue + prefix = prefix.rstrip("/") + if not md.get("no_resource_target_path"): + prefix += "/" + md["uri"].replace(".", "/") + for path, sid in self.qml_files.get(key, []): + smd = qml_metadata(self.nodes[sid]) + if resource_url(prefix, smd.get("value", "")) == logical: + matches.extend(self.module_index.components.get(path, [])) + evidence.extend((mid, sid)) + return answer(matches, reason="resource_alias_unavailable", evidence=evidence) diff --git a/graphify/qt_project_membership.py b/graphify/qt_project_membership.py new file mode 100644 index 0000000000..557f86bd19 --- /dev/null +++ b/graphify/qt_project_membership.py @@ -0,0 +1,199 @@ +"""Publish literal Qt metadata membership over accepted, immutable source facts.""" +from __future__ import annotations + +import hashlib +from pathlib import Path, PurePosixPath + +from graphify.extractors.qml_project_read import literal_path +from graphify.extractors.qml_source_identity import relative_qml_source +from graphify.qml_resolution_types import Resolution, answer, fact_edge, fact_node, qml_metadata, source_path +from graphify.qt_source_file_role import generic_file_role + +_CONTEXTS = {"qt_membership_site", "qt_project_source", "qt_resource_membership"} + + +def _ast(node, fresh_ids): + origin = node.get("_origin") + return origin == "ast" or (origin is None and node.get("id") in fresh_ids) + + +def _location(node, span): + start, end = span["start_row"] + 1, span["end_row"] + 1 + if node.get("source_location") not in {f"L{start}", f"L{start}-L{end}"}: + raise ValueError("QML_METADATA: membership declaration location conflicts with span") + + +def _span(metadata): + """Invalid transported locations are integrity failures, not guessed spans.""" + value = metadata.get("span") + fields = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column") + if (not isinstance(value, dict) + or any(type(value.get(key)) is not int or value[key] < 0 for key in fields) + or value["end_byte"] <= value["start_byte"] or value["end_row"] < value["start_row"] + or (value["start_row"] == value["end_row"] + and value["end_column"] - value["start_column"] != value["end_byte"] - value["start_byte"])): + raise ValueError("QML_METADATA: invalid membership source span") + return dict(value) + + +def _unsupported(metadata): + if metadata.get("generated"): + return "generated_membership_unestablished" + if metadata.get("conditional"): + return "conditional_membership_unestablished" + return "" + + +def _file_role(node, path, fresh_ids): + metadata = qml_metadata(node) + if not _ast(node, fresh_ids) or node.get("_callable") or node.get("_callable_class"): + return False + if metadata.get("kind") == "file": + return node.get("type") == "file" and node.get("label") == PurePosixPath(path).name + return generic_file_role(node, path, fresh_ids) + + +def _target(index, path, fresh_ids): + """A QML component precedes its file node; other sources need an exact file role.""" + components = index.module_index.components.get(path, []) + if components: + records = [index.nodes[nid] for nid in components] + reason = next((_unsupported(qml_metadata(node)) for node in records + if _unsupported(qml_metadata(node))), "") + if reason: + return Resolution("unsupported", reason=reason) + accepted = [node["id"] for node in records if _ast(node, fresh_ids) + and node.get("type") == "class"] + return answer(accepted, reason="membership_component_provenance_unavailable") + files = [nid for nid in index.module_index.by_file.get(path, []) + if _file_role(index.nodes[nid], path, fresh_ids)] + return answer(files, reason="membership_target_outside_accepted_corpus") + + +def _module(index, declaration, metadata, fresh_ids): + ids = sorted(set(index.modules.get(metadata.get("module_key"), []))) + if len(ids) != 1: + return answer(ids, reason="membership_module_unavailable") + mid = ids[0] + module = index.nodes[mid] + md = qml_metadata(module) + if not _ast(module, fresh_ids) or module.get("type") != "namespace": + return Resolution("unavailable", reason="membership_module_provenance_unavailable") + _location(module, _span(md)) + if index.paths[mid] != index.paths[declaration["id"]]: + return Resolution("unavailable", reason="membership_module_source_conflict") + if md.get("kind") != "qt_module" or md.get("module_key") != metadata.get("module_key"): + return Resolution("unavailable", reason="membership_module_unavailable") + if reason := _unsupported(md): + return Resolution("unsupported", reason=reason) + return Resolution("resolved", mid, evidence=(mid,)) + + +def _source(index, declaration, metadata, fresh_ids): + module = _module(index, declaration, metadata, fresh_ids) + if module.status != "resolved": + return module + path = literal_path(index.paths[declaration["id"]], metadata.get("value") or "") + if path is None: + return Resolution("unavailable", reason="membership_path_outside_root", evidence=module.evidence) + target = _target(index, path, fresh_ids) + return Resolution(target.status, target.target_id, target.reason, + tuple(dict.fromkeys(module.evidence + target.evidence)), target.candidates) + + +def _resource(index, declaration, metadata, fresh_ids): + path = literal_path(index.paths[declaration["id"]], metadata.get("value") or "") + if path is None or path != metadata.get("target_path"): + return Resolution("unavailable", reason="membership_path_outside_root_or_conflicting") + result = index.resource_index.resolve_url(metadata.get("logical_url") or "") + if result.status != "resolved": + return result + accepted = _target(index, path, fresh_ids) + if accepted.target_id != result.target_id: + return Resolution(accepted.status if accepted.status != "resolved" else "ambiguous", + reason=accepted.reason or "membership_resource_target_conflict", + evidence=result.evidence, candidates=accepted.candidates) + return Resolution("resolved", result.target_id, evidence=result.evidence) + + +def _remove_owned_edges(edges, site_id): + # Only this resolver's mechanisms are replaced; semantic/user relationships + # incident on the same accepted identity keep their independent ownership. + edges[:] = [edge for edge in edges if not (edge.get("context") in _CONTEXTS + and (edge.get("source") == site_id or edge.get("target") == site_id))] + + +def _accepted_input_source(path: Path | str, root: Path) -> str | None: + """Compare the physically admitted walked owner without lending target roles.""" + # Native/root spellings canonicalize, while separately discovered aliases + # stay distinct. Exact lexical equality rejects facts borrowed from another + # owner even when those inputs happen to share a contained physical target. + try: + return relative_qml_source(Path(path), root) + except (OSError, RuntimeError, ValueError): + return None + + +def resolve_project_memberships(results, nodes, edges, *, root: Path, project_index, + fresh_ast_ids=()): + """Append fresh source-owned sites and replace stale derived scratch records. + + Return fresh nodes/edges explicitly: a replacement of borrowed context may + occur before a caller's append-count boundary. Input dictionaries stay + immutable; the caller owns scratch publication and durable failure policy. + """ + fresh_ids = set(fresh_ast_ids) + declarations = [] + for path, result in results.items(): + for node in list(result.get("nodes", [])): + md = qml_metadata(node) + if md.get("kind") in {"qt_source", "resource_alias"}: + declarations.append((node["id"], path, result, node, md)) + derived_nodes, derived_edges = [], [] + for _, path, result, declaration, metadata in sorted(declarations, key=lambda item: item[0]): + file = source_path(declaration, Path(root)) + if (file is None or file != _accepted_input_source(path, root) + or declaration["id"] not in project_index.nodes): + raise ValueError("QML_METADATA: membership declaration source is not accepted") + span = _span(metadata) + _location(declaration, span) + if not _ast(declaration, fresh_ids): + resolution = Resolution("unavailable", reason="membership_declaration_provenance_unavailable") + elif result.get("qml_failures") or result.get("error") or result.get("partial"): + resolution = Resolution("unsupported", reason="metadata_source_incomplete") + elif reason := _unsupported(metadata): + resolution = Resolution("unsupported", reason=reason) + elif metadata["kind"] == "qt_source": + resolution = _source(project_index, declaration, metadata, fresh_ids) + else: + resolution = _resource(project_index, declaration, metadata, fresh_ids) + key = hashlib.sha256(declaration["id"].encode("utf-8")).hexdigest() + site = fact_node(file, "membership_resolution", key, metadata.get("value") or "membership", + span["start_row"] + 1, span=span, + declaration_id=declaration["id"], declaration_kind=metadata["kind"], + source_kind=metadata.get("source_kind") or "resource", + module_key=metadata.get("module_key") or "", + logical_url=metadata.get("logical_url") or "", alias=metadata.get("alias") or "", + status=resolution.status, reason=resolution.reason, + diagnostic_code="QML-RESOLVE-001" if resolution.status != "resolved" else "", + evidence=list(resolution.evidence[:50]), candidates=list(resolution.candidates[:50]), + target_id=resolution.target_id or "") + owned = [fact_edge(declaration["id"], site["id"], "contains", declaration, + "qt_membership_site", span=span, declaration_id=declaration["id"])] + if resolution.target_id: + owned.append(fact_edge(site["id"], resolution.target_id, "references", declaration, + "qt_project_source" if metadata["kind"] == "qt_source" else "qt_resource_membership", + span=span, declaration_id=declaration["id"], module_key=metadata.get("module_key") or "", + logical_url=metadata.get("logical_url") or "", evidence=list(resolution.evidence[:50]))) + # Replacing a dictionary in scratch cannot mutate the previously saved + # context object. Canonical source declarations are never rewritten. + nodes[:] = [node for node in nodes if node.get("id") != site["id"]] + _remove_owned_edges(edges, site["id"]) + nodes.append(site) + edges.extend(owned) + result["nodes"] = [node for node in result.get("nodes", []) if node.get("id") != site["id"]] + [site] + _remove_owned_edges(result.setdefault("edges", []), site["id"]) + result["edges"].extend(owned) + derived_nodes.append(site) + derived_edges.extend(owned) + return derived_nodes, derived_edges diff --git a/graphify/qt_qml_access_index.py b/graphify/qt_qml_access_index.py new file mode 100644 index 0000000000..297a1c1d29 --- /dev/null +++ b/graphify/qt_qml_access_index.py @@ -0,0 +1,265 @@ +"""Accepted component, objectName and source-local handle lookup for Qt access.""" +from __future__ import annotations + +import base64 + +from graphify.extractors.qt_cpp_identity import source_reference_key +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qml_resolution import build_qml_index +from graphify.qml_resolution_types import Resolution, answer, qml_metadata, source_path +from graphify.qt_project_index import QtProjectIndex +from graphify.qt_qml_bridge import build_qt_qml_bridge + + +class QtQmlAccessIndex: + def __init__(self, nodes, edges, *, root, project_index=None): + self.nodes = {node["id"]: node for node in nodes} + self.project = project_index or QtProjectIndex(nodes, edges, root=root) + self.native = build_qt_qml_bridge(nodes, edges, root=root, project_index=self.project) + self.qml = build_qml_index(nodes, edges, root=root, native_index=self.native, project_index=self.project) + self.loads, self.handles, self.names = {}, {}, {} + self.declarations, self.unsafe_parent_components, self.qobject_types = {}, set(), {} + self.native_classes, self.native_ancestry, self.qobject_reasons = {}, {}, {} + for node in self.native.nodes.values(): + md = qt_metadata(node) + if md.get("kind") == "class" and md.get("class_name"): + self.native_classes.setdefault(md["class_name"], []).append((node, md)) + for edge in edges: + child = self.qml.nodes.get(edge.get("target"), {}) + md = qml_metadata(edge) + if (edge.get("relation") == "contains" and edge.get("confidence") == "EXTRACTED" + and md.get("kind") == "declaration" and md.get("span") == qml_metadata(child).get("span") + and source_path(edge, self.qml.root) == source_path(child, self.qml.root)): + self.declarations.setdefault(edge["target"], set()).add(edge["source"]) + for node in self.qml.nodes.values(): + metadata = qml_metadata(node) + if metadata.get("construction_parent_dynamic"): + self.mark_parent_mutation(node["id"]) + if metadata.get("kind") in {"property", "binding"} and metadata.get("raw_name") == "objectName" and isinstance(metadata.get("literal_value"), str): + path = self.qml.paths[node["id"]] + key = (path, metadata.get("component_key"), metadata["literal_value"]) + self.names.setdefault(key, set()).update(self.qml.objects.get((path, metadata.get("component_key"), metadata.get("object_scope_key")), [])) + + def load(self, metadata): + # Literal text alone cannot authorize a rejected SDK overload/owner. + if metadata.get("loader_supported") is False: + return Resolution("unsupported", reason=metadata.get("loader_reason") or "loader_overload_unestablished") + if metadata.get("conditional"): + return Resolution("dynamic", reason="conditional_component_load") + if metadata.get("operation") == "loadFromModule": + if not isinstance(metadata.get("module_uri"), str) or not isinstance(metadata.get("type_name"), str): + return Resolution("dynamic", reason="computed_module_or_type") + return self.project.resolve_component(metadata["module_uri"], metadata["type_name"]) + if not isinstance(metadata.get("literal_url"), str): + return Resolution("dynamic", reason="computed_url") + return self.project.resolve_url(metadata["literal_url"], metadata.get("base_url")) + + def root_object(self, component_id): + component = self.nodes.get(component_id, {}) + metadata = qml_metadata(component) + if metadata.get("kind") == "file": + candidates = [node["id"] for node in self.nodes.values() if node.get("source_file") == component.get("source_file") and qml_metadata(node).get("kind") == "component"] + result = answer(candidates, reason="component_unavailable") + return self.root_object(result.target_id) if result.target_id else result + candidates = [node["id"] for node in self.nodes.values() if node.get("source_file") == component.get("source_file") + and qml_metadata(node).get("kind") == "object" and qml_metadata(node).get("component_key") == metadata.get("component_key") + and not qml_metadata(node).get("parent_scope_key")] + return answer(candidates, reason="component_root_unavailable") + + def mark_parent_mutation(self, object_id): + """Invalidate only the accepted component containing a proved mutation.""" + node = self.qml.nodes.get(object_id) + if node is not None: + self.unsafe_parent_components.add((self.qml.paths[object_id], qml_metadata(node).get("component_key"))) + + def _native_ancestry(self, name, seen=()): + """Prove non-widget ancestry from accepted canonical base facts only. + + Q_OBJECT establishes metaobject capability, not QObject parenting: Qt's + creator treats QWidget instances specially. Unknown SDK bases/aliases + cannot prove that this exception is absent. A complete base-free mixin + is non-widget but supplies no QObject ancestry of its own. + """ + if name in seen or len(seen) >= 32: + return False, False + if name in self.native_ancestry: + return self.native_ancestry[name] + records = self.native_classes.get(name) + if records is None: + return (True, True) if name == "QObject" else (False, False) + definitions = [(node, md) for node, md in records if md.get("is_definition") is True] + result = False, False + if len(definitions) == 1: + node, md = definitions[0] + target = self.nodes.get(md.get("class_id"), {}) + bases = md.get("canonical_base_names") + if (self._native_class_proof(node, md, target) + and md.get("canonical_base_status") == "resolved" and isinstance(bases, list) + and len(bases) == len(md.get("bases", [])) <= 50 + and all(isinstance(base, str) and base for base in bases)): + proofs = [self._native_ancestry(base, seen + (name,)) for base in bases] + result = all(safe for safe, _ in proofs), any(qobject for _, qobject in proofs) + self.native_ancestry[name] = result + return result + + def _native_class_proof(self, node, md, target): + cpp = target.get("metadata", {}).get("cpp_class", {}) + encoded = cpp.get("qualified_name_b64") + if not isinstance(encoded, str) or len(encoded) > 512: + return False + try: + qualified = base64.b64decode(encoded, validate=True).decode("utf-8") + except (ValueError, UnicodeError): + return False + return (node.get("_origin") == "ast" and md.get("status") == "resolved" + and target.get("_callable_class") is True and md.get("generic_target_id") == target.get("id") + and cpp.get("contract_version") == 1 and cpp.get("is_definition") is True and not cpp.get("ambiguous") + and qualified == md.get("class_name") and cpp.get("span") == md.get("span") + and source_path(node, self.qml.root) == source_path(target, self.qml.root)) + + def _native_parent_type(self, provider_id): + class_id = self.native.metadata(provider_id).get("class_id") + names = {name for name, records in self.native_classes.items() + if any(md.get("class_id") == class_id for _, md in records)} + return len(names) == 1 and all(self._native_ancestry(next(iter(names)))) + + def _qobject_type(self, object_id, seen=()): + """Admit bounded Qt object types or a unique source-defined base chain. + + An unknown custom class, gadget or widget cannot inherit ownership from + its spelling. Local providers precede the bounded builtin import view. + """ + if object_id in seen or len(seen) >= 32: + return False + if object_id in self.qobject_types: + return self.qobject_types[object_id] + md = qml_metadata(self.qml.nodes[object_id]) + path, name = self.qml.paths[object_id], md.get("type_name") or "" + result = self.qml.resolve_type(path, md.get("component_key") or "", name) + if result.target_id: + if self.native.is_provider(result.target_id): + # This view admits only complete, accepted QObject definitions + # with a literal registration and source/build module evidence. + provider = self.native.metadata(result.target_id) + accepted = bool(provider.get("creatable") and not provider.get("singleton") + and self._native_parent_type(result.target_id)) + if not accepted: + self.qobject_reasons[object_id] = "native_construction_ancestry_unestablished" + else: + roots = self.qml._root_objects(result.target_id) + accepted = len(roots) == 1 and self._qobject_type(roots[0], seen + (object_id,)) + elif result.status == "ambiguous": + accepted = False + else: + parts = name.split(".") + accepted = False + for import_id in self.qml.imports.get(path, []): + imp = qml_metadata(self.qml.nodes[import_id]) + qualifier = imp.get("qualifier") or "" + if imp.get("import_kind") != "module" or qualifier != (parts[0] if len(parts) == 2 else ""): + continue + module, type_name = imp.get("value"), parts[-1] + accepted |= (module in {"QtQml", "QtQuick"} and type_name in {"QtObject", "Component", "Timer", "Connections"} + or module == "QtQuick" and type_name in {"Item", "Rectangle", "Text", "Image", "MouseArea"}) + self.qobject_types[object_id] = accepted + return accepted + + def _construction_parent(self, child_id): + child, md = self.qml.nodes[child_id], qml_metadata(self.qml.nodes[child_id]) + scope = md.get("construction_parent_scope_key") + if "construction_parent_scope_key" not in md: + return Resolution("unavailable", reason="construction_parent_evidence_missing") + if not scope: + return Resolution("unavailable", reason="construction_root") + path, component = self.qml.paths[child_id], md.get("component_key") + parents = self.qml.objects.get((path, component, scope), []) + parent = answer(parents, reason="construction_parent_unavailable") + if not parent.target_id: + return parent + owner_ids = self.declarations.get(child_id, set()) + if len(owner_ids) != 1: + return Resolution("unavailable", reason="construction_owner_unestablished") + owner = self.qml.nodes.get(next(iter(owner_ids)), {}) + parent_node = self.qml.nodes[parent.target_id] + omd, pmd = qml_metadata(owner), qml_metadata(parent_node) + role = md.get("construction_parent_kind") + owner_kind = "property" if role == "property" else "object" if role in {"default", "binding"} else "" + if (child.get("_origin") != "ast" or owner.get("_origin") != "ast" or parent_node.get("_origin") != "ast" + or omd.get("kind") != owner_kind or omd.get("object_scope_key") != scope + or omd.get("component_key") != component or source_path(owner, self.qml.root) != path + or not self._qobject_type(child_id) or not self._qobject_type(parent.target_id) + or not (omd.get("span", {}).get("start_byte", -1) < md.get("span", {}).get("start_byte", -1) + and md.get("span", {}).get("end_byte", -1) <= omd.get("span", {}).get("end_byte", -1)) + or not (pmd.get("span", {}).get("start_byte", -1) < md.get("span", {}).get("start_byte", -1) + and md.get("span", {}).get("end_byte", -1) <= pmd.get("span", {}).get("end_byte", -1))): + reason = next((self.qobject_reasons[nid] for nid in (child_id, parent.target_id) + if nid in self.qobject_reasons), "construction_parent_unestablished") + return Resolution("unavailable", reason=reason) + return Resolution("resolved", parent.target_id, evidence=(child_id, next(iter(owner_ids)), parent.target_id)) + + def _descendant(self, target, receiver, direct): + seen, evidence = set(), set() + while target not in seen and len(seen) < 256: + seen.add(target) + parent = self._construction_parent(target) + if parent.reason == "construction_root": + return False, evidence, "" + if parent.target_id is None: + return None, evidence, parent.reason + evidence.update(parent.evidence) + if parent.target_id == receiver: + return True, evidence, "" + if direct: + return False, evidence, "" + target = parent.target_id + return None, evidence, "construction_cycle_or_limit" + + def find_child(self, root_id, name, *, options="recursive"): + """Search the evidenced construction subtree, never component-wide names.""" + if options not in {"recursive", "direct"}: + return Resolution("unsupported", reason="find_child_options_unsupported") + if root_id not in self.qml.nodes or qml_metadata(self.qml.nodes[root_id]).get("kind") != "object": + return Resolution("unavailable", reason="qml_receiver_unestablished") + md = qml_metadata(self.qml.nodes[root_id]) + key = self.qml.paths[root_id], md.get("component_key") + candidates = self.names.get((*key, name), set()) - {root_id} + if candidates and key in self.unsafe_parent_components: + return Resolution("dynamic", reason="component_parenting_mutated", candidates=tuple(sorted(candidates))[:50]) + resolved, uncertain, evidence, reasons = [], [], set(), set() + for target in sorted(candidates): + inside, proof, reason = self._descendant(target, root_id, options == "direct") + evidence.update(proof) + if inside: + resolved.append(target) + elif inside is None: + uncertain.append(target) + reasons.add(reason) + if uncertain: + reason = "native_construction_ancestry_unestablished" if "native_construction_ancestry_unestablished" in reasons else "object_parenting_unestablished" + return Resolution("unavailable", reason=reason, candidates=tuple(sorted(resolved + uncertain))[:50]) + return answer(resolved, reason="object_name_unavailable", evidence=tuple(sorted(evidence))[:50]) + + def member(self, object_id, name, operation): + if object_id not in self.qml.nodes or qml_metadata(self.qml.nodes[object_id]).get("kind") != "object": + return Resolution("unavailable", reason="qml_receiver_unestablished") + result = self.qml.follow_member(object_id, [name]) + if not result.target_id: + return result + metadata = self.qml.md(result.target_id) + kind = metadata.get("kind") + supported = {"function", "signal"} if operation == "invokeMethod" else {"property"} + if operation in {"setProperty", "write"} and "readonly" in metadata.get("modifiers", []): + return Resolution("unsupported", reason="readonly_property") + return result if kind in supported else Resolution("unavailable", reason="member_role_mismatch") + + def handle(self, metadata, reference=None): + key = source_reference_key(metadata) + entry = self.handles.get(key) if key else None + if entry is None: + status = metadata.get("receiver_identity_status") + return Resolution(status if status in {"dynamic", "ambiguous", "unsupported"} else "unavailable", + reason=metadata.get("receiver_identity_reason") or "qml_handle_unestablished") + target, assignment, condition = entry + if condition or assignment != metadata.get("receiver_assignment_byte", -1): + return Resolution("dynamic", reason="conditional_or_reassigned_handle") + return Resolution("resolved", target, evidence=("source_handle",)) diff --git a/graphify/qt_qml_access_resolution.py b/graphify/qt_qml_access_resolution.py new file mode 100644 index 0000000000..677f22a7b7 --- /dev/null +++ b/graphify/qt_qml_access_resolution.py @@ -0,0 +1,103 @@ +"""Resolve literal C++ access to accepted QML source, never instantiate components.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import qt_edge, qt_metadata, update_qt +from graphify.extractors.qt_cpp_identity import source_reference_key +from graphify.qml_resolution_types import Resolution +from graphify.qml_resolution_types import qml_metadata +from graphify.qt_qml_access_index import QtQmlAccessIndex +from graphify.qt_qml_event_access import resolve_qml_event_access +from graphify.qt_context_bindings import project_context_access, resolve_context_bindings +from graphify.qt_qml_bridge import build_qt_qml_bridge + + +def _annotate(site, resolution): + update_qt(site, status=resolution.status, target_id=resolution.target_id or "", reason=resolution.reason, + candidates=list(resolution.candidates), evidence=list(resolution.evidence), bridge_direction="cpp_to_qml") + + +def resolve_qt_qml_access(per_file, all_nodes, all_edges, *, root, project_index=None): + """Only fresh facts are annotated; prior accepted dictionaries are lookup context.""" + index = QtQmlAccessIndex(all_nodes, all_edges, root=root, project_index=project_index) + results = list(per_file.values() if isinstance(per_file, dict) else per_file) + sites = sorted([(site, result) for result in results if result for site in result.get("nodes", []) + if qt_metadata(site).get("kind") in {"qml_load", "qml_root", "qml_access", "qml_parent_mutation", "context_exposure", "initial_properties"}], + key=lambda pair: (pair[0]["source_file"], qt_metadata(pair[0]).get("span", {}).get("start_byte", -1))) + for site, result in sites: + metadata, edges = qt_metadata(site), [] + kind, operation = metadata["kind"], metadata.get("operation") + key = source_reference_key(metadata) + context = "qt_cpp_qml_load" + if kind == "qml_load": + if key: + resolution = index.load(metadata) + index.loads.setdefault(key, []).append(resolution) + else: + status = metadata.get("receiver_identity_status") + resolution = Resolution(status if status in {"dynamic", "ambiguous", "unsupported"} else "unavailable", + reason=metadata.get("receiver_identity_reason") or "loader_declaration_unestablished") + elif kind == "qml_root": + loads = index.loads.get(key, []) + # Creation-context rejection precedes otherwise valid source loading. + if metadata.get("loader_supported") is False: + resolution = Resolution("unsupported", reason=metadata.get("loader_reason") or "loader_overload_unestablished") + elif len(loads) != 1 or not loads[0].target_id: + resolution = Resolution("ambiguous" if len(loads) > 1 else "unavailable", reason="unique_source_loader_unestablished") + elif operation == "rootObjects" and not metadata.get("single_root_selection"): + resolution = Resolution("unsupported", reason="root_list_requires_static_selection") + else: + resolution = index.root_object(loads[0].target_id) + elif kind == "qml_parent_mutation": + # Only an established QML receiver invalidates its accepted tree; + # unrelated QObject mutations grant no component identity. + handle = index.handle(metadata) + if handle.target_id: + index.mark_parent_mutation(handle.target_id) + resolution = Resolution("dynamic", reason="runtime_parent_mutation") + elif kind == "qml_access" and metadata.get("reflection_supported") is False: + # A valid QObject handle cannot lend SDK authority to a source + # reflection class. Rejection must precede member/handle seeding. + resolution = Resolution("unsupported", reason="reflection_api_type_unestablished") + elif kind == "qml_access": + if metadata.get("handle_role") == "root": + loads = index.loads.get(key, []) + handle = index.root_object(loads[0].target_id) if len(loads) == 1 and loads[0].target_id else Resolution("unavailable", reason="unique_source_loader_unestablished") + else: + handle = index.handle(metadata) + if not handle.target_id: + resolution = handle + elif operation in {"read", "write"} and metadata.get("receiver_type") == "QQmlProperty" and qml_metadata(index.nodes[handle.target_id]).get("kind") == "property": + resolution = handle + context = "qt_cpp_qml_property_write" if operation == "write" else "qt_cpp_qml_property_read" + elif not isinstance(metadata.get("lookup_name"), str): + resolution = Resolution("dynamic", reason="computed_access_name") + elif operation == "findChild": + resolution = (index.find_child(handle.target_id, metadata["lookup_name"], + options=metadata.get("find_child_options", "unsupported")) + if metadata.get("find_child_type_supported") else + Resolution("unsupported", reason="find_child_type_unsupported")) + context = "qt_cpp_qml_find_child" + else: + resolution = index.member(handle.target_id, metadata["lookup_name"], operation) + context = "qt_cpp_qml_invoke" if operation == "invokeMethod" else "qt_cpp_qml_property_write" if operation in {"setProperty", "write"} else "qt_cpp_qml_property_read" + else: + # Provider names alone do not establish a runtime engine/component + # binding. Preserve the literal exposure for the bridge profile. + resolution = Resolution("unavailable", reason="provider_component_scope_unestablished") + context = "qt_context_exposure" if kind == "context_exposure" else "qt_initial_property" + _annotate(site, resolution) + if resolution.target_id: + relation = "calls" if operation == "invokeMethod" and index.qml.md(resolution.target_id).get("kind") == "function" else "uses" + edges.append(qt_edge(site, resolution.target_id, relation, context, bridge_direction="cpp_to_qml")) + assigned = source_reference_key(metadata, "assigned") + if assigned and kind in {"qml_root", "qml_access"}: + index.handles[assigned] = (resolution.target_id, metadata.get("assignment_byte", -1), bool(metadata.get("conditional"))) + known = {(edge["source"], edge["target"], edge.get("context")) for edge in result.get("edges", [])} + additions = [edge for edge in edges if (edge["source"], edge["target"], edge.get("context")) not in known] + result.setdefault("edges", []).extend(additions) + if all_edges is not result["edges"]: + all_edges.extend(additions) + bindings = resolve_context_bindings(results, index, all_nodes, all_edges) + bridge = build_qt_qml_bridge(all_nodes, all_edges, root=root, project_index=index.project) + project_context_access(results, bridge, bindings, all_nodes, all_edges, index.qml) + resolve_qml_event_access(results, index, all_nodes, all_edges) diff --git a/graphify/qt_qml_bridge.py b/graphify/qt_qml_bridge.py new file mode 100644 index 0000000000..4fbf58fb34 --- /dev/null +++ b/graphify/qt_qml_bridge.py @@ -0,0 +1,113 @@ +"""Scoped Qt native providers for QML indexes; no guessed global class aliases.""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qml_resolution_types import Resolution, answer, source_path +from graphify.qt_qml_bridge_members import QtMemberViews + + +class QtQmlBridgeIndex(QtMemberViews): + """Build one source/provider table over an already accepted corpus.""" + + def __init__(self, nodes, edges=(), *, root: Path, project_index=None): + nodes = list(nodes) + self.nodes = {node["id"]: node for node in nodes if node.get("id") and source_path(node, Path(root)) is not None} + self.provider_records, self.modules, self.unresolved, self.module_bounds = {}, {}, {}, {} + super().__init__(self.nodes.values()) + classes = {} + for node in self.nodes.values(): + md = qt_metadata(node) + if md.get("kind") == "class" and md.get("class_id") and md.get("is_definition") is True: + classes.setdefault(md["class_id"], []).append(md) + for node in self.nodes.values(): + md = qt_metadata(node) + if md.get("kind") != "registration": + continue + value = dict(md) + value["evidence"] = (node["id"],) + if value.get("status") != "resolved" or value.get("class_id") not in self.nodes: + self.unresolved[node["id"]] = value + continue + if value.get("module_required"): + context = project_index.module_context(value["module_source_file"]) if project_index else None + if project_index is None or context is None or context.target_id is None: + value.update(status=context.status if context else "unavailable", + reason=context.reason if context else "module_metadata_unavailable") + self.unresolved[node["id"]] = value + continue + module = project_index.module_metadata(context.target_id) + value.update(uri=module.get("uri"), major=module.get("major"), minor=module.get("minor"), + evidence=value["evidence"] + context.evidence + (context.target_id,)) + if isinstance(value["major"], int) and isinstance(value["minor"], int): + value["module_minor"] = value["minor"] + added = value.get("added_version") or [value["major"], 0] + value["minor"] = added[1] if added[0] == value["major"] else 0 + if not value.get("uri") or not isinstance(value.get("major"), int) or not isinstance(value.get("minor"), int): + value.update(status="unavailable", reason="literal_module_version_unavailable") + self.unresolved[node["id"]] = value + continue + observed = classes.get(value["class_id"], []) + if not observed: + value.update(status="unavailable", reason="native_class_definition_unavailable") + self.unresolved[node["id"]] = value + continue + if not any(item.get("is_qobject") for item in observed): + value.update(status="unsupported", reason="native_class_metaobject_unavailable") + self.unresolved[node["id"]] = value + continue + # Version bounds authorize only accepted native providers. Rejected + # gadgets or missing classes cannot create a namespace with no type. + if "module_minor" in value: + self.module_bounds.setdefault(value["uri"], set()).add((value["major"], value["module_minor"])) + self.provider_records[node["id"]] = value + self.modules.setdefault(value["uri"], []).append(node["id"]) + + def _version(self, uri, major, minor): + ids = self.modules.get(uri, []) + versions = {(self.provider_records[nid]["major"], self.provider_records[nid]["minor"]) for nid in ids} + bounds = self.module_bounds.get(uri, set()) + versions |= bounds + if not versions: + return None + if major is None: + return max(versions) + if minor is None: + choices = [version for version in versions if version[0] == major] + return max(choices) if choices else None + return (major, minor) if (major, minor) in versions or any(ma == major and minor <= maximum for ma, maximum in bounds) else None + + def module_import(self, uri, major=None, minor=None): + """Multiple native types in one module provide one namespace, not ambiguity.""" + version = self._version(uri, major, minor) + if version is None: + return Resolution("unavailable", reason="native_module_version_unavailable") + ids = [nid for nid in self.modules[uri] if self.provider_records[nid]["major"] == version[0]] + evidence = tuple(sorted({item for nid in ids for item in self.provider_records[nid]["evidence"]})) + # This ID is namespace evidence only; type lookup independently checks exports. + return Resolution("resolved", sorted(ids)[0], evidence=evidence) + + def module_type(self, uri, major, minor, name, *, importer="", **kwargs): + version = self._version(uri, major, minor) + if version is None: + return Resolution("unavailable", reason="native_module_version_unavailable") + candidates = [] + for nid in self.modules[uri]: + md = self.provider_records[nid] + added = tuple(md.get("added_version") or (md["major"], 0)) + removed = tuple(md["removed_version"]) if md.get("removed_version") else None + if md.get("raw_name") != name or md.get("anonymous") or md["major"] != version[0]: + continue + if md["minor"] > version[1] or added > version or (removed and version >= removed): + continue + candidates.append(nid) + if candidates: + latest = max(self.provider_records[nid]["minor"] for nid in candidates) + candidates = [nid for nid in candidates if self.provider_records[nid]["minor"] == latest] + evidence = tuple(sorted({item for nid in candidates for item in self.provider_records[nid]["evidence"]})) + return answer(candidates, reason="native_type_unavailable", evidence=evidence) + + +def build_qt_qml_bridge(nodes, edges=(), *, root, project_index=None): + return QtQmlBridgeIndex(nodes, edges, root=Path(root), project_index=project_index) diff --git a/graphify/qt_qml_bridge_members.py b/graphify/qt_qml_bridge_members.py new file mode 100644 index 0000000000..4c59f18d6c --- /dev/null +++ b/graphify/qt_qml_bridge_members.py @@ -0,0 +1,137 @@ +"""Read-only native member views with overload ambiguity before ID deduplication.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qml_resolution_types import Resolution + + +class QtMemberViews: + """QML lookups borrow canonical C++ methods and distinct Qt property facts.""" + nodes: dict[str, dict] + provider_records: dict[str, dict] + + def __init__(self, nodes): + self.native_members, self.native_views, self.native_proofs = {}, {}, {} + self.notify_accessors = {} + for node in nodes: + md = qt_metadata(node) + if md.get("kind") in {"property", "member"} and md.get("class_id"): + self.native_members.setdefault((md["class_id"], md.get("raw_name")), []).append(node) + if md.get("kind") == "property_accessor" and md.get("accessor_role") == "notify": + self.notify_accessors.setdefault(md.get("property_id"), []).append(node) + + def member(self, provider_id, name): + provider = self.provider_records.get(provider_id) + if provider is None: + return Resolution("unavailable", reason="native_provider_unknown") + class_id = provider["class_id"] + declarations = self.native_members.get((class_id, name), []) + properties = [node for node in declarations if qt_metadata(node).get("kind") == "property"] + methods = [node for node in declarations if qt_metadata(node).get("kind") == "member" + and qt_metadata(node).get("access") == "public" + and set(qt_metadata(node).get("roles", [])) & {"invokable", "signal", "slot"}] + signatures = {qt_metadata(node).get("signature") for node in methods} + if len(properties) > 1 or len(signatures) > 1 or (properties and methods): + return Resolution("ambiguous", reason="native_member_or_overload_ambiguous", + candidates=tuple(sorted(node["id"] for node in properties + methods))[:50]) + selected = properties[0] if properties else methods[0] if methods else None + if selected is None: + return Resolution("unavailable", reason="native_member_not_qml_visible") + md = qt_metadata(selected) + if md.get("revision") or md.get("revisions"): + return Resolution("unsupported", reason="native_member_revision_requires_supported_version") + if md.get("status") != "resolved": + return Resolution(md.get("status") or "unavailable", reason=md.get("reason") or "native_member_unavailable") + target = selected["id"] if properties else md.get("generic_target_id") + if not target or target not in self.nodes: + return Resolution("unavailable", reason="canonical_native_member_unavailable") + role = "property" if properties else "signal" if "signal" in md.get("roles", []) else "function" + self.native_views[target] = {"kind": role, "raw_name": name, "raw_type": md.get("raw_type") or md.get("return_type", ""), + "class_id": class_id, "qt_native": True, "span": md["span"], + "source_file": selected["source_file"], "generic_target_id": target, + "provider_id": provider_id} + if role == "signal": + self.native_views[target]["parameter_names"] = list(md.get("parameter_names", [])) + evidence = tuple(sorted(set(provider["evidence"] + (provider_id, selected["id"])))) + self.native_proofs[(target, provider_id)] = {"class_id": class_id, "member_fact_id": selected["id"], + "canonical_target_id": target, "provider_id": provider_id, + "evidence": list(evidence), "kind": role} + return Resolution("resolved", target, evidence=evidence) + + def property_notify(self, property_id, provider_id): + """QML property-change spelling projects to the proven C++ NOTIFY signal. + + A property never fabricates a native signal. Its accepted accessor fact + and public signal view must agree on the same canonical endpoint, class + and source span; ambiguity or missing notification remains explicit. + """ + provider, prop = self.provider_records.get(provider_id), self.nodes.get(property_id) + if provider is None or prop is None: + return Resolution("unavailable", reason="native_property_notify_unavailable") + md = qt_metadata(prop) + if md.get("kind") != "property" or md.get("class_id") != provider["class_id"] or md.get("owner_id") != provider["class_id"]: + return Resolution("unavailable", reason="native_property_notify_provenance_unavailable") + accepted = self.member(provider_id, md.get("raw_name")) + if accepted.status != "resolved" or accepted.target_id != property_id: + return accepted + if md.get("constant") or not isinstance(md.get("notify"), str): + return Resolution("unavailable", reason="native_property_has_no_notify_signal") + accessors = self.notify_accessors.get(property_id, []) + if len(accessors) != 1: + return Resolution("ambiguous" if accessors else "unavailable", reason="native_property_notify_accessor_not_unique", + candidates=tuple(sorted(node["id"] for node in accessors))[:50]) + accessor, link = accessors[0], qt_metadata(accessors[0]) + if (link.get("owner_id") != property_id or link.get("span") != md.get("span") + or accessor.get("source_file") != prop.get("source_file") or link.get("raw_name") != md["notify"]): + return Resolution("unavailable", reason="native_property_notify_provenance_unavailable") + if link.get("status") != "resolved": + return Resolution(link.get("status") or "unavailable", reason=link.get("reason") or "native_property_notify_unavailable") + signal = self.member(provider_id, md["notify"]) + if signal.status != "resolved": + return signal + if (self.metadata(signal.target_id).get("kind") != "signal" + or signal.target_id != link.get("generic_target_id")): + return Resolution("unsupported", reason="native_property_notify_is_not_signal") + evidence = tuple(sorted(set(accepted.evidence + signal.evidence + (accessor["id"],)))) + return Resolution("resolved", signal.target_id, "native_property_notify_signal", evidence) + + def endpoint_proof(self, target_id, evidence=(), *, kind=None): + if target_id in self.provider_records and target_id in evidence: + provider = self.provider_records[target_id] + return {"kind": kind or "component", "provider_id": target_id, "canonical_target_id": target_id, + "class_id": provider["class_id"], "evidence": list(evidence)} + proofs = [proof for (target, _), proof in self.native_proofs.items() if target == target_id + and proof["member_fact_id"] in evidence and proof["provider_id"] in evidence] + if len(proofs) != 1: + return {} + # Endpoint role and occurrence-specific lookup evidence have separate + # owners. Do not overwrite the shared signal proof when two properties + # use one NOTIFY endpoint; copy the current accepted accessor evidence. + proof = dict(proofs[0]) + proof["evidence"] = list(dict.fromkeys(proof["evidence"] + [item for item in evidence if item in self.nodes])) + return proof + + def metadata(self, target_id): + if isinstance(target_id, dict): + target_id = target_id.get("id") + if target_id in self.provider_records: + md = self.provider_records[target_id] + return {"kind": "component", "raw_name": md.get("raw_name"), "singleton": md.get("singleton", False), + "class_id": md["class_id"], "qt_native": True, "creatable": md.get("creatable", False)} + return dict(self.native_views.get(target_id, {})) + + def is_provider(self, target_id): + return target_id in self.provider_records + + def context_member(self, binding_node, name): + """A source/engine-scoped context provider does not require a type export.""" + md = qt_metadata(binding_node) + class_id = md.get("provider_target_id") + if md.get("kind") != "context_binding" or md.get("status") != "resolved" or class_id not in self.nodes: + return Resolution("unavailable", reason="context_provider_provenance_unavailable") + nid = binding_node["id"] + if nid not in self.nodes or md.get("component_target_id") not in self.nodes: + return Resolution("unavailable", reason="context_component_provenance_unavailable") + self.provider_records[nid] = {"class_id": class_id, "raw_name": md.get("exposed_name") or "", + "evidence": tuple(md.get("evidence") or ()) + (nid,)} + return self.member(nid, name) diff --git a/graphify/qt_qml_callback_proof.py b/graphify/qt_qml_callback_proof.py new file mode 100644 index 0000000000..3564c53a2e --- /dev/null +++ b/graphify/qt_qml_callback_proof.py @@ -0,0 +1,110 @@ +"""Revalidate subscription callbacks against borrowed, source-owned QML facts. + +The ordinary QML index owns nonlexical lookup. Captured JS binders remain separate +authority; neither a matching label nor another function in the component repairs +a changed subscription. No corpus source is read or executed at this boundary. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extractors.qml_facts import qml_metadata +from graphify.qml_scope import QmlProjectIndex + + +def _span(node): + value = qml_metadata(node).get("span", {}) + fields = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column") + return value if (isinstance(value, dict) and all(type(value.get(key)) is int and value[key] >= 0 for key in fields) + and value["start_byte"] < value["end_byte"] and value["start_row"] <= value["end_row"]) else {} + + +def _within(node, owner): + span, enclosing = _span(node), _span(owner) + return bool(span and enclosing and enclosing["start_byte"] <= span["start_byte"] + and span["end_byte"] <= enclosing["end_byte"]) + + +def _ancestors(source, nodes): + """Follow bounded lexical owners, checking source/scope containment at each hop.""" + result, child = [], source + while qml_metadata(child).get("owner_id"): + identity = qml_metadata(child)["owner_id"] + if identity in result or identity not in nodes or len(result) >= 32: + return None + owner = nodes[identity] + md, parent = qml_metadata(child), qml_metadata(owner) + if (owner.get("source_file") != source.get("source_file") or not _within(child, owner) + or md.get("component_key") != parent.get("component_key") + or md.get("object_scope_key") != parent.get("object_scope_key")): + return None + result.append(identity) + child = owner + return result + + +def valid_subscription_callback(subscription, metadata, nodes): + """Bind a derived site to its original call or its exact Connections handler.""" + # Mapping keys carry identity after JSON reload; producer dictionaries carry + # it directly. Borrow a normalized view without changing either representation. + by_id = {identity: {**node, "id": identity} for identity, node in nodes.items()} + owner_id, handler_id = metadata.get("owner_id"), metadata.get("handler_target_id") + if owner_id not in by_id or handler_id not in by_id: + return False + source, handler = by_id[owner_id], by_id[handler_id] + md, hmd = qml_metadata(source), qml_metadata(handler) + if (md.get("contract_version") != 1 or hmd.get("contract_version") != 1 + or source.get("source_file") != subscription.get("source_file") + or handler.get("source_file") != source.get("source_file") + or subscription.get("source_location") != source.get("source_location") + or metadata.get("span") != _span(source) + or hmd.get("component_key") != md.get("component_key")): + return False + if metadata.get("subscription_form") == "Connections": + enclosing = by_id.get(md.get("owner_id"), {}) + short = str(md.get("raw_name") or "").rsplit(".", 1)[-1] + return (owner_id == handler_id and md.get("kind") == "handler" and md.get("connections") is True + and md.get("connection_supplied") is True and bool(md.get("connection_target")) + and not md.get("disabled_reason") and not md.get("attached_prefix") + and short.startswith("on") and short[2:3].isupper() + and md.get("reference") == short[2:3].lower() + short[3:] + and qml_metadata(enclosing).get("kind") == "object" + and qml_metadata(enclosing).get("type_name") == "Connections" + and _ancestors(source, by_id) is not None) + if (metadata.get("subscription_form") != "signal.connect" or md.get("kind") != "call" + or not isinstance(md.get("reference"), str) or not md["reference"].endswith(".connect") + or type(md.get("callback_argument_count")) is not int or md["callback_argument_count"] != 1 + or not isinstance(md.get("callback_reference"), str) or not md["callback_reference"] + or hmd.get("kind") not in {"function", "js_function"}): + return False + owners = _ancestors(source, by_id) + if not owners: + return False + facts = [node for node in by_id.values() if node.get("source_file") == source.get("source_file") + and qml_metadata(node).get("contract_version") == 1] + index = QmlProjectIndex(facts, [], root=Path(".")) + file = index.paths.get(owner_id) + # QML functions belong to an exact indexed object; local JS declarations + # borrow their lexical owner. Both must stay inside the original source span. + if hmd.get("kind") == "function": + containers = index.objects.get((index.paths.get(handler_id), hmd.get("component_key"), + hmd.get("object_scope_key")), []) + if len(containers) != 1 or not _within(handler, index.nodes[containers[0]]): + return False + elif not _ancestors(handler, by_id): + return False + callback = md["callback_reference"] + if md.get("callback_lexical_shadowed") is True: + # Local functions and recursion have source-AST binder IDs. A formal, + # reassigned variable or implicit native parameter has no callable binder. + parent = hmd.get("owner_id") + return (md.get("callback_lexical_target_id") == handler_id and callback == hmd.get("raw_name") + and hmd.get("object_scope_key") == md.get("object_scope_key") + and (handler_id in owners or parent in owners) + and (handler_id in owners or parent in by_id and _within(handler, by_id[parent]))) + if md.get("callback_lexical_shadowed") is not False or md.get("callback_lexical_target_id"): + return False + # Resolve the literal callback in the original object/component scope. This + # uses the same production lookup as projection, rather than a global name scan. + return bool(file and index.resolve_member(file, md.get("component_key", ""), + md.get("object_scope_key", ""), callback).target_id == handler_id) diff --git a/graphify/qt_qml_event_access.py b/graphify/qt_qml_event_access.py new file mode 100644 index 0000000000..162b55eaf6 --- /dev/null +++ b/graphify/qt_qml_event_access.py @@ -0,0 +1,115 @@ +"""Explicit legacy meta-object connections across established C++/QML handles.""" +from __future__ import annotations + +import re + +from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt +from graphify.qml_resolution_types import Resolution, qml_metadata +from graphify.qt_event_index import QtEventIndex, value_type +from graphify.qt_event_resolution import _role + + +def _qml_endpoint(index, metadata, prefix): + expression = metadata.get("signal" if prefix == "sender" else "receiver", {}) + if expression.get("form") != "legacy": + return Resolution("unsupported", reason="qml_endpoint_requires_literal_meta_signature") + handle = index.handle({**metadata, "receiver_reference": metadata.get(prefix + "_reference"), + "receiver_declaration_id": metadata.get(prefix + "_declaration_id"), + "receiver_assignment_byte": metadata.get(prefix + "_assignment_byte", -1)}) + if not handle.target_id: + return handle + result = index.member(handle.target_id, expression.get("member_name", ""), "invokeMethod") + if not result.target_id: + return result + declaration = qml_metadata(index.nodes[result.target_id]) + if prefix == "sender" and declaration.get("kind") != "signal": + return Resolution("unavailable", reason="qml_sender_is_not_signal") + signature = declaration.get("signature", "").strip().strip("()") + parameters = [] + names = {"int": "int", "real": "double", "double": "double", "bool": "bool", "string": "QString", "var": "QVariant"} + for value in signature.split(",") if signature else []: + match = re.fullmatch(r"\s*(\w+)\s+\w+\s*", value) + annotation = re.fullmatch(r"\s*\w+\s*:\s*(\w+)\s*", value) + untyped = re.fullmatch(r"\s*\w+\s*", value) + raw_type = match[1] if match else annotation[1] if annotation else "var" if untyped and declaration.get("kind") == "function" else "" + if raw_type not in names: + return Resolution("unsupported", reason="qml_meta_signature_unestablished") + parameters.append(names[raw_type]) + if parameters != expression.get("parameter_types", []): + return Resolution("unavailable", reason="qml_meta_signature_mismatch") + return result + + +def resolve_qml_event_access(results, index, all_nodes, all_edges): + native = QtEventIndex(all_nodes) + for result in results: + if not result: + continue + additions, edges = [], [] + for site in result.get("nodes", []): + metadata = qt_metadata(site) + if metadata.get("kind") not in {"connect", "disconnect"} or metadata.get("status") == "resolved": + continue + qml_sender = _qml_endpoint(index, metadata, "sender") + qml_receiver = _qml_endpoint(index, metadata, "receiver") + signal = qml_sender if qml_sender.target_id else native.member(metadata.get("signal", {}), metadata.get("sender_type", ""), role="signal", owner_class=metadata.get("owner_class", "")) + receiver = qml_receiver if qml_receiver.target_id else native.member(metadata.get("receiver", {}), metadata.get("receiver_type", ""), role=metadata.get("receiver", {}).get("role") or None, owner_class=metadata.get("owner_class", "")) + if not qml_sender.target_id and not qml_receiver.target_id: + continue + if not signal.target_id or not receiver.target_id: + update_qt(site, status="unavailable", reason="cross_language_endpoint_unestablished") + continue + offered = metadata.get("signal", {}).get("parameter_types", []) if qml_sender.target_id else qt_metadata(native.nodes[signal.target_id]).get("parameter_types", []) + required = metadata.get("receiver", {}).get("parameter_types", []) if qml_receiver.target_id else qt_metadata(native.nodes[receiver.target_id]).get("parameter_types", []) + if len(required) > len(offered) or [value_type(v) for v in offered[:len(required)]] != [value_type(v) for v in required]: + update_qt(site, status="unavailable", reason="cross_language_signature_mismatch") + continue + direction = "qml_to_cpp" if qml_sender.target_id else "cpp_to_qml" + update_qt(site, status="resolved", reason="", signal_target_id=signal.target_id, receiver_target_id=receiver.target_id, bridge_direction=direction) + # Each occurrence owns its endpoint annotation. Revisiting earlier + # accumulated edges changes their operation/direction and recursively + # encodes raw literal transport; update_qt retains only semantic values. + site_nodes, site_edges = [], [] + _role(site, signal.target_id, "signal", signal, site_nodes, site_edges) + _role(site, receiver.target_id, "receiver", receiver, site_nodes, site_edges) + for node in site_nodes: + update_qt(node, bridge_direction=direction) + for edge in site_edges: + values = qt_metadata(edge) + update_qt(edge, bridge_direction=direction) + if edge.get("relation") == "references": + edge["context"] = "qt_qml_" + metadata["kind"] + "_" + values.get("role", "") + # Upgrade only the same occurrence's partial native role + # edge. Leaving both contexts lets raw pair deduplication + # retain the older incomplete bridge instead of this proof. + old_context = "qt_" + metadata["kind"] + "_" + values.get("role", "") + for previous in result.get("edges", []): + if (previous.get("context") == old_context + and previous.get("relation") == "references" + and previous.get("source") == edge["source"] + and previous.get("target") == edge["target"] + and previous.get("source_file") == site["source_file"]): + previous["context"] = edge["context"] + additions.extend(site_nodes) + edges.extend(site_edges) + # The native pass may already own one endpoint of this fresh occurrence. + # Reuse that canonical role ID and annotate its source-owned dictionary; + # a duplicate filtered below must not leave the native placeholder stale. + owned = {node["id"]: node for node in result.get("nodes", [])} + for node in additions: + if node["id"] in owned: + update_qt(owned[node["id"]], bridge_direction=qt_metadata(node)["bridge_direction"]) + known = set(owned) + fresh = [node for node in additions if node["id"] not in known] + result.setdefault("nodes", []).extend(fresh) + all_nodes.extend(fresh) + edge_keys = {(edge["source"], edge["target"], edge.get("context")) for edge in result.get("edges", [])} + owned_edges = {(edge["source"], edge["target"], edge.get("context")): edge + for edge in result.get("edges", [])} + for edge in edges: + previous = owned_edges.get((edge["source"], edge["target"], edge.get("context"))) + if previous is not None: + update_qt(previous, bridge_direction=qt_metadata(edge)["bridge_direction"]) + fresh_edges = [edge for edge in edges if (edge["source"], edge["target"], edge.get("context")) not in edge_keys] + result.setdefault("edges", []).extend(fresh_edges) + all_edges.extend(fresh_edges) diff --git a/graphify/qt_qml_pipeline.py b/graphify/qt_qml_pipeline.py new file mode 100644 index 0000000000..93685e337b --- /dev/null +++ b/graphify/qt_qml_pipeline.py @@ -0,0 +1,106 @@ +"""Join accepted Qt/QML facts after canonical C++ identities are finalized.""" +from __future__ import annotations + +from pathlib import Path + +from graphify.qml_resolution_types import source_path + + +def _diagnostic_source(path: Path, root: Path) -> str: + """Return a bounded relative label without granting source or target authority.""" + # Match the existing safety display's 160-character bound. Diagnostic + # fallback must not retry a failed identity operation without a guard, leak + # a foreign root, or truncate a label into a different apparent source. + try: + relative = source_path({"source_file": str(path.resolve())}, Path(root).resolve()) + except (OSError, RuntimeError, ValueError): + relative = source_path({"source_file": str(path)}, Path(root)) + if (relative is None or len(relative) > 160 + or any(ord(char) < 32 or 127 <= ord(char) < 160 or char in "\u2028\u2029" + for char in relative)): + return "" + return relative + + +def resolve_qt_qml(paths, per_file, all_nodes, all_edges, *, root, + context_nodes=(), context_edges=(), import_roots=None): + """Publish successful scratch joins; never annotate borrowed context facts.""" + from graphify.extractors.qt_cpp_access import collect_qt_cpp_access + from graphify.extractors.qt_cpp_events import collect_qt_cpp_events + from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp + from graphify.qt_event_resolution import resolve_qt_events + from graphify.qt_qml_access_resolution import resolve_qt_qml_access + from graphify.qt_qml_bridge import build_qt_qml_bridge + from graphify.qt_project_index import QtProjectIndex + from graphify.qt_project_membership import resolve_project_memberships + from graphify.qt_source_containment import attach_qt_file_sites + + fresh_ids = {node["id"] for node in all_nodes} + nodes = all_nodes + [node for node in context_nodes or () if node.get("id") not in fresh_ids] + edges = all_edges + list(context_edges or ()) + initial_ids = {node["id"] for node in nodes} + initial_edges = {id(edge) for edge in edges} + results = {path: result for path, result in zip(paths, per_file) if result} + active_paths, active_results = list(results), list(results.values()) + + def collect(collector): + counts = [(len(result.get("nodes", [])), len(result.get("edges", []))) for result in active_results] + collector(active_paths, active_results, root=root, accepted_nodes=nodes, accepted_edges=edges) + known = {node["id"] for node in nodes} + for result, (n0, e0) in zip(active_results, counts): + for node in result.get("nodes", [])[n0:]: + if node["id"] not in known: + nodes.append(node) + known.add(node["id"]) + edges.extend(result.get("edges", [])[e0:]) + + try: + collect(enrich_qt_cpp) + collect(collect_qt_cpp_events) + collect(collect_qt_cpp_access) + project_index = QtProjectIndex(nodes, edges, root=root, import_roots=import_roots) + # Packaging membership is source evidence independent of QML use/load. + # Only fresh declarations publish decisions over the accepted corpus. + membership_nodes, _ = resolve_project_memberships( + results, nodes, edges, root=root, project_index=project_index, + fresh_ast_ids=fresh_ids) + native_index = build_qt_qml_bridge(nodes, edges, root=root, project_index=project_index) + if any(node.get("metadata", {}).get("qml") for node in nodes): + from graphify.qml_relationships import resolve_qml_relationships + from graphify.qml_resolution import resolve_qml_project + resolve_qml_project(results, nodes, edges, root=root, import_roots=import_roots, native_index=native_index, project_index=project_index) + resolve_qml_relationships(results, nodes, edges, root=root, import_roots=import_roots, native_index=native_index, project_index=project_index) + resolve_qt_events(results, nodes, edges, root=root) + resolve_qt_qml_access(results, nodes, edges, root=root, project_index=project_index) + # Unresolved callable/class identity does not erase observed membership + # in a uniquely accepted source file; this grants no native target role. + edges.extend(attach_qt_file_sites(results, nodes, edges, root=root, fresh_ast_ids=fresh_ids)) + for diagnostic in project_index.diagnostics: + for path, result in results.items(): + if Path(path).resolve().relative_to(root).as_posix() == diagnostic["source_file"]: + result.setdefault("diagnostics", []).append(dict(diagnostic)) + break + except Exception: + # Do not expose backend errors or let partial joins reach a graph writer. + for path, result in results.items(): + path = Path(path) + if path.suffix.lower() not in {".qml", ".js", ".mjs", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".hxx", ".qmltypes", ".cmake", ".pro", ".pri", ".qrc"} and path.name not in {"qmldir", "CMakeLists.txt"}: + continue + # A join can fail because this same input no longer resolves. Its + # diagnostic label is display context only; unavailable context is + # explicit and cannot interrupt annotation of the remaining files. + relative = _diagnostic_source(path, root) + result.setdefault("qml_failures", []).append({"code": "QML_RESOLUTION_FAILED", "source_file": relative}) + result.setdefault("diagnostics", []).append({"code": "QML_RESOLUTION_FAILED", "severity": "error", + "owner": "qt_qml_resolution", "source_file": relative, "message": "Qt/QML project join failed", + "recovery": "Correct the join failure and retry; prior graph is preserved."}) + else: + # Derived membership decisions can replace borrowed same-ID sites. + # Publish fresh facts by identity, so removal of stale context cannot + # shift list offsets or leak unrelated borrowed nodes into this result. + membership_ids = {node["id"] for node in membership_nodes} + all_nodes.extend(node for node in nodes if node["id"] not in initial_ids + or (node["id"] in membership_ids and node["id"] not in fresh_ids)) + all_edges.extend(edge for edge in edges if id(edge) not in initial_edges) + + return [str(path) for path, result in results.items() if result.get("qml_failures") or result.get("qt_failures")] diff --git a/graphify/qt_qml_projection.py b/graphify/qt_qml_projection.py new file mode 100644 index 0000000000..631ac72249 --- /dev/null +++ b/graphify/qt_qml_projection.py @@ -0,0 +1,185 @@ +"""Validate versioned endpoint evidence for explicit Qt/QML language bridges.""" +from pathlib import Path + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_context_paths import valid_context_chain, valid_context_endpoint +from graphify.qt_qml_callback_proof import valid_subscription_callback + +_CPP = {".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".hxx"} + + +def is_typed_qt_qml_edge(source, target, edge): + """Select existing bridge producers independently of their relation/context/confidence. + + Classification grants no authority: even malformed transport selected here + must pass the full consumer proof. Context-binding ownership/exposure remains + under its separate contract rather than being reclassified as member access. + """ + src = Path(source.get("source_file") or "").suffix.lower() + tgt = Path(target.get("source_file") or "").suffix.lower() + if not (src == ".qml" and tgt in _CPP or src in _CPP and tgt == ".qml"): + return False + source_metadata, edge_metadata = source.get("metadata"), edge.get("metadata") + target_metadata = target.get("metadata") + source_qt = source_metadata.get("qt", {}) if isinstance(source_metadata, dict) else {} + target_qt = target_metadata.get("qt", {}) if isinstance(target_metadata, dict) else {} + edge_qt = edge_metadata.get("qt", {}) if isinstance(edge_metadata, dict) else {} + producer_kinds = {"context_access", "context_subscription", "qml_load", "qml_root", "qml_access", "event_endpoint"} + return (isinstance(edge_qt, dict) and (edge_qt.get("kind") == "qml_bridge" or "native_endpoint" in edge_qt) + or any(isinstance(value, dict) and value.get("kind") in producer_kinds for value in (source_qt, target_qt))) + + +def _context_mechanism(source, source_qt, owner, proof, edge): + """A derived context access borrows its original read/call, never a new operation.""" + if source_qt.get("kind") == "context_subscription": + return edge.get("relation") == "references" and edge.get("context") == "qt_context_subscription" + md = qml_metadata(owner) + expected = "calls" if md.get("kind") == "call" and proof.get("kind") == "function" else "uses" + return (md.get("contract_version") == 1 and md.get("kind") in {"read", "call"} + and md.get("status") == "unavailable" and not md.get("lexical_shadowed") and not md.get("script_file") + and owner.get("source_file") == source.get("source_file") + and owner.get("source_location") == source.get("source_location") + and md.get("span") == source_qt.get("span") + and edge.get("context") == "qt_context_member" and edge.get("relation") == expected) + + +def _native_mechanism(md, proof, member, edge, target_id): + """Registered native APIs retain source expression roles and endpoint declaration roles.""" + kind, role = md.get("kind"), proof.get("kind") + if (md.get("status") != "resolved" or kind == "handler" and ( + md.get("disabled_reason") or md.get("attached_prefix") + or md.get("connections") and (not md.get("connection_supplied") or not md.get("connection_target")))): + return False + if role in {"module", "component"}: + expected = ("import_resolution", "imports", "qml_import_resolution") if role == "module" else ( + "type_use", "uses", "qml_type_resolution") + return (kind, edge.get("relation"), edge.get("context")) == expected + roles = set(member.get("roles", [])) + declared = "property" if member.get("kind") == "property" else "signal" if "signal" in roles else ( + "function" if roles & {"invokable", "slot"} else "") + if (role != declared or md.get("resolved_target_id") != target_id or member.get("status") != "resolved" + or member.get("kind") == "member" and member.get("access") != "public"): + return False + expected = {"read": ("uses", "qml_binding_read"), "alias": ("references", "qml_alias_target"), + "handler": ("references", "qml_signal_subscription") if role == "signal" else None, + "call": ("uses", "qml_signal_emit") if role == "signal" else ( + "calls", "qml_js_call") if role == "function" else None}.get(kind) + return expected == (edge.get("relation"), edge.get("context")) + + +def _cpp_mechanism(source, md, target_md, edge, nodes, target_id): + """Reverse operations and connection endpoints have separate observed mechanisms.""" + kind, operation, target_kind = md.get("kind"), md.get("operation"), target_md.get("kind") + if (md.get("conditional") or md.get("status") != "resolved" + or md.get("loader_supported") is False or md.get("reflection_supported") is False): + return False + if kind == "event_endpoint": + owner = nodes.get(md.get("owner_id"), {}) + parent = qt_metadata(owner) + role = md.get("role") + return (role in {"signal", "receiver"} and parent.get("kind") in {"connect", "disconnect"} + and parent.get("status") == "resolved" and not parent.get("conditional") + and md.get("bridge_direction") == parent.get("bridge_direction") + == ("qml_to_cpp" if role == "signal" else "cpp_to_qml") + and parent.get(role + "_target_id") == target_id + and md.get("span") == parent.get("span") + and owner.get("source_file") == source.get("source_file") + and owner.get("source_location") == source.get("source_location") + and target_kind in ({"signal"} if role == "signal" else {"signal", "function"}) + and edge.get("relation") == "references" + and edge.get("context") == "qt_qml_" + parent["kind"] + "_" + role) + if kind == "qml_load": + expected = ("uses", "qt_cpp_qml_load") if (target_kind in {"component", "file"} + and operation in {"load", "loadFromModule", "loadUrl", "setSource", "component_constructor", "engine_url_constructor"}) else None + elif kind == "qml_root": + expected = ("uses", "qt_cpp_qml_load") if (target_kind == "object" + and operation in {"rootObject", "rootObjects", "create", "createWithInitialProperties"}) else None + elif kind == "qml_access": + if operation == "findChild": + expected = ("uses", "qt_cpp_qml_find_child") if target_kind == "object" else None + elif operation == "invokeMethod": + expected = ("calls" if target_kind == "function" else "uses", "qt_cpp_qml_invoke") if target_kind in {"function", "signal"} else None + elif operation in {"setProperty", "write", "property", "read", "QQmlProperty"}: + expected = ("uses", "qt_cpp_qml_property_write" if operation in {"setProperty", "write"} else "qt_cpp_qml_property_read") if target_kind == "property" else None + else: + expected = None + else: + expected = None + return md.get("bridge_direction") == "cpp_to_qml" and expected == (edge.get("relation"), edge.get("context")) + + +def allows_qt_qml_edge(source, target, edge, *, source_id, target_id, nodes): + """A context label cannot authorize a bridge to an unrelated declaration.""" + if edge.get("confidence") != "INFERRED" or edge.get("source_file") != source.get("source_file"): + return False + src_ext = Path(source.get("source_file") or "").suffix.lower() + tgt_ext = Path(target.get("source_file") or "").suffix.lower() + try: + smd, tmd = qml_metadata(source), qml_metadata(target) + source_qt = qt_metadata(source) + # A retained endpoint ID cannot repair a changed source span or mechanism. + source_span = source_qt.get("span") if source_qt else smd.get("span") + if not source_span or qt_metadata(edge).get("span") != source_span: + return False + if src_ext == ".qml" and tgt_ext in _CPP and source_qt.get("kind") in {"context_access", "context_subscription"}: + proof = qt_metadata(edge).get("endpoint_proof", {}) + binding_id, owner_id = source_qt.get("binding_id"), source_qt.get("owner_id") + if not isinstance(proof, dict) or binding_id not in nodes or owner_id not in nodes: + return False + binding = qt_metadata(nodes[binding_id]) + component_id, class_id = binding.get("component_target_id"), binding.get("provider_target_id") + member_id = proof.get("member_fact_id") + if component_id not in nodes or class_id not in nodes or member_id not in nodes: + return False + component, owner, member = qml_metadata(nodes[component_id]), qml_metadata(nodes[owner_id]), qt_metadata(nodes[member_id]) + subscription = source_qt.get("kind") == "context_subscription" + if subscription and (proof.get("kind") != "signal" or source_qt.get("handler_target_id") not in nodes): + return False + handler = nodes.get(source_qt.get("handler_target_id"), {}) if subscription else {} + if subscription and (handler.get("source_file") != source.get("source_file") + or qml_metadata(handler).get("kind") not in {"handler", "function", "js_function"} + or qml_metadata(handler).get("component_key") != component.get("component_key") + or not valid_subscription_callback(source, source_qt, nodes)): + return False + expected = member_id if member.get("kind") == "property" else member.get("generic_target_id") + return (source_qt.get("status") == "resolved" and binding.get("kind") == "context_binding" + and source_qt.get("bridge_direction") == qt_metadata(edge).get("bridge_direction") == "qml_to_cpp" + and binding.get("status") == "resolved" and source_qt.get("target_id") == target_id + and proof.get("canonical_target_id") == target_id and proof.get("provider_id") == binding_id + and valid_context_chain(binding, proof, nodes) and valid_context_endpoint(proof, nodes) + and member.get("class_id") == proof.get("class_id") + and expected == target_id and component.get("component_key") == owner.get("component_key") + and nodes[component_id].get("source_file") == source.get("source_file") + and _context_mechanism(source, source_qt, nodes[owner_id], proof, edge)) + if smd.get("contract_version") == 1 and src_ext == ".qml" and tgt_ext in _CPP: + proof = qt_metadata(edge).get("native_endpoint", {}) + if not isinstance(proof, dict) or proof.get("canonical_target_id") != target_id: + return False + provider_id, class_id = proof.get("provider_id"), proof.get("class_id") + if provider_id not in nodes or class_id not in nodes: + return False + provider = qt_metadata(nodes[provider_id]) + if provider.get("kind") != "registration" or provider.get("class_id") != class_id: + return False + if proof.get("kind") in {"module", "component"}: + return target_id == provider_id and _native_mechanism(smd, proof, {}, edge, target_id) + member_id = proof.get("member_fact_id") + if member_id not in nodes: + return False + member = qt_metadata(nodes[member_id]) + expected = member_id if member.get("kind") == "property" else member.get("generic_target_id") + return (member.get("class_id") == class_id and expected == target_id + and member.get("kind") in {"property", "member"} + and _native_mechanism(smd, proof, member, edge, target_id)) + if src_ext in _CPP and tgt_ext == ".qml" and tmd.get("contract_version") == 1: + proof = qt_metadata(edge) + return (source_qt.get("kind") in {"qml_load", "qml_root", "qml_access", "event_endpoint", "context_exposure", "initial_properties"} + and source_qt.get("target_id") == target_id and proof.get("target_id") == target_id + and proof.get("bridge_direction") in {"cpp_to_qml", "qml_to_cpp"} + and proof.get("bridge_direction") == source_qt.get("bridge_direction") + and source_qt.get("status") == "resolved" + and _cpp_mechanism(source, source_qt, tmd, edge, nodes, target_id)) + except (ValueError, TypeError): + return False + return False diff --git a/graphify/qt_qml_search.py b/graphify/qt_qml_search.py new file mode 100644 index 0000000000..8394accfd4 --- /dev/null +++ b/graphify/qt_qml_search.py @@ -0,0 +1,98 @@ +"""Bounded semantic search views for Qt/QML facts; identities stay opaque.""" +from __future__ import annotations + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata + +SEARCH_NAMESPACE = "graphify_qt_qml" +_FIELDS = {"kind", "raw_name", "raw_type", "type_name", "class_name", "signature", "uri", + "qualifier", "import_kind", "mechanism", "status", "reason", "metadata_format", "target_name", + "declared_type", "callable_form", "operation", "bridge_direction"} +_FLAGS = {"singleton", "creatable", "anonymous", "internal", "required", "readonly", "source_q_object"} +_LISTS = {"roles", "flags"} +MAX_TEXT = 384 +MAX_ROLES = 16 + + +def _text(value): + return value if isinstance(value, str) and len(value) <= MAX_TEXT else None + + +def _view(values): + if values.get("contract_version") != 1: + return {} + result: dict[str, object] = {key: text for key in sorted(_FIELDS) if (text := _text(values.get(key))) is not None and text} + for key in sorted(_LISTS): + values_list = values.get(key) + if isinstance(values_list, list) and len(values_list) <= MAX_ROLES: + result[key] = [value for value in values_list if _text(value)] + result.update({key: values[key] for key in sorted(_FLAGS) if isinstance(values.get(key), bool)}) + if values.get("kind") in {"import", "module_import", "script_import"}: + value = _text(values.get("value")) + if value: + result["import_value"] = value + major, minor = values.get("major"), values.get("minor") + if type(major) is int and 0 <= major <= 1_000_000: + result["major"] = major + if type(minor) is int and 0 <= minor <= 1_000_000: + result["minor"] = minor + return result + + +def _decode_view(name, raw, decode): + """Decode only bounded search fields, never copy unrelated payload trees.""" + selected = {key: value for key in _FIELDS | _FLAGS | {"major", "minor", "value"} + if (value := raw.get(key)) is not None and + (isinstance(value, (bool, int)) or _text(value) is not None)} + for key in _LISTS: + values = raw.get(key) + if isinstance(values, list) and len(values) <= MAX_ROLES and all(_text(value) is not None for value in values): + selected[key] = list(values) + literals = raw.get("raw_values", {}) + if not isinstance(literals, dict) or len(literals) > 100: + return {} + selected["contract_version"] = 1 + encoded = {} + for key, value in literals.items(): + if not isinstance(key, str) or len(key) > 128: + return {} + if key not in selected and not any(key.startswith(name + "/") and name in selected for name in _LISTS): + continue + # Validate before the Qt decoder copies the packet through JSON. A + # nested malformed value must never reach that recursive copy. + if not isinstance(value, str) or len(value) > 512: + return {} + encoded[key] = value + selected["raw_values"] = encoded + return _view(decode({"metadata": {name: selected}})) + + +def search_attributes(node): + """Return a new attribute view without editing source nodes or old attributes. + + Only public semantic fields are indexed. Source IDs, opaque scope keys, + transport copies, expression bodies and supplied literal data are excluded. + Existing use of the reserved namespace wins instead of being overwritten. + Malformed/future metadata cannot make ordinary graph search fail. + """ + attrs = node.get("attributes") + result = dict(attrs) if isinstance(attrs, dict) else {} + if SEARCH_NAMESPACE in result: + return result + metadata = node.get("metadata") + if not isinstance(metadata, dict): + return result + views = {} + for name, decode in (("qml", qml_metadata), ("qt", qt_metadata)): + raw = metadata.get(name) + if not isinstance(raw, dict) or type(raw.get("contract_version")) is not int or raw.get("contract_version") != 1: + continue + try: + values = _decode_view(name, raw, decode) + except (ValueError, TypeError, UnicodeError): + continue + if values: + views[name] = values + if views: + result[SEARCH_NAMESPACE] = views + return result diff --git a/graphify/qt_relationship_views.py b/graphify/qt_relationship_views.py new file mode 100644 index 0000000000..30f2c4da00 --- /dev/null +++ b/graphify/qt_relationship_views.py @@ -0,0 +1,67 @@ +"""HTML semantic relationship views keep source dependencies distinct from calls.""" +from __future__ import annotations + +from html import escape + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata + +_LABELS = { + "qt_signal_emit": "signal emission", "qt_connect_signal": "connection signal", "qt_connect_receiver": "connection receiver", + "qt_disconnect_signal": "disconnect signal", "qt_disconnect_receiver": "disconnect receiver", "qt_disconnect": "disconnect handle", + "qt_qml_connect_signal": "QML connection signal", "qt_qml_connect_receiver": "QML connection receiver", + "qt_qml_disconnect_signal": "QML disconnect signal", "qt_qml_disconnect_receiver": "QML disconnect receiver", + "qml_signal_subscription": "signal subscription", "qml_signal_emit": "QML signal emission", + "qml_binding_read": "binding read", "qml_alias_target": "alias target", "qml_js_call": "JavaScript call", "qml_script_call": "imported script call", + "qml_import_resolution": "QML import", "qml_type_resolution": "QML type", "qml_type_base": "QML base type", + "qt_cpp_qml_load": "component or object access", "qt_cpp_qml_find_child": "objectName lookup", + "qt_cpp_qml_property_read": "QML property read", "qt_cpp_qml_property_write": "QML property write", "qt_cpp_qml_invoke": "QML method invocation", + "qt_context_exposure": "context provider", "qt_initial_property": "initial property provider", "qt_context_member": "QML context member", +} +_EVENTS = {key for key in _LABELS if "connect" in key or "signal" in key} +_KINDS = {"type_use", "import_resolution", "read", "call", "handler", "alias", "qml_load", "qml_root", "qml_access", "context_exposure", "initial_properties", "connect", "disconnect", "emission"} + + +def relationship_label(edge): + """A free-form context string alone is not versioned source evidence.""" + if edge.get("context") not in _LABELS: + return None + try: + if qml_metadata(edge).get("contract_version") != 1 and qt_metadata(edge).get("contract_version") != 1: + return None + except (ValueError, TypeError): + return None + return _LABELS[edge["context"]] + + +def is_event_relationship(edge): + return edge.get("context") in _EVENTS and relationship_label(edge) is not None + + +def render_relationship_table(nodes, edges, lang="en"): + """Render accepted links and explicit unresolved source facts as HTML text.""" + del lang + by_id = {node["id"]: node for node in nodes} + rows = [] + for edge in edges: + label = relationship_label(edge) + source, target = by_id.get(edge.get("source")), by_id.get(edge.get("target")) + if not label or not source or not target: + continue + location = str(edge.get("source_file") or "") + ":" + str(edge.get("source_location") or "") + rows.append('' + + escape(source.get("label") or source["id"]) + '' + escape(label) + '' + escape(target.get("label") or target["id"]) + + '' + escape(location) + '' + escape(str(edge.get("confidence") or "")) + '') + for node in nodes: + try: + metadata = qml_metadata(node) or qt_metadata(node) + except (ValueError, TypeError): + continue + if metadata.get("kind") not in _KINDS or metadata.get("status") not in {"ambiguous", "dynamic", "unsupported", "unavailable"}: + continue + rows.append('' + escape(node.get("label") or node["id"]) + + '' + escape(metadata["status"]) + '' + escape(str(metadata.get("reason") or "target unestablished")) + + '' + escape(str(node.get("source_file") or "") + ':' + str(node.get("source_location") or "")) + 'source fact') + if not rows: + return "" + return '

Qt/QML source relationships

Connections and subscriptions describe source facts; runtime delivery is unverified.

' + ''.join(rows) + '
SourceRelationship / statusTarget / reasonLocationEvidence
' diff --git a/graphify/qt_resource_index.py b/graphify/qt_resource_index.py new file mode 100644 index 0000000000..051075a06c --- /dev/null +++ b/graphify/qt_resource_index.py @@ -0,0 +1,107 @@ +"""Immutable accepted-fact URL lookups; no runtime URL base or source expansion.""" +from __future__ import annotations + +import posixpath +from pathlib import Path +from urllib.parse import unquote, urljoin, urlsplit + +from graphify.extractors.qml_resources import resource_url +from graphify.qml_resolution_types import Resolution, answer, qml_metadata, source_path + + +def normalize_url(literal_url: str, base_url: str | None = None) -> str | Resolution: + """Normalize only supported explicit URL syntax, including the runtime base.""" + if not isinstance(literal_url, str) or not literal_url or len(literal_url) > 384: + return Resolution("unsupported", reason="invalid_url_literal") + if literal_url.startswith(":/"): + literal_url = "qrc" + literal_url + try: + parsed = urlsplit(literal_url) + path = unquote(parsed.path, errors="strict") + except (ValueError, UnicodeError): + return Resolution("unsupported", reason="invalid_url_encoding_or_syntax") + if parsed.query or parsed.fragment or parsed.netloc: + return Resolution("unsupported", reason="url_query_fragment_or_authority") + if "\x00" in path or "\\" in path or any(part in {".", ".."} for part in path.split("/")): + return Resolution("unavailable", reason="url_path_escape") + if parsed.scheme == "qrc": + logical = resource_url("/", path.lstrip("/")) if path.startswith("/") else None + return logical or Resolution("unsupported", reason="invalid_resource_url") + if parsed.scheme == "file": + return "file:" + parsed.path + if parsed.scheme: + return Resolution("unsupported", reason="unsupported_url_scheme") + if not base_url: + return Resolution("unavailable", reason="runtime_url_base_unavailable") + base = normalize_url(base_url) + if isinstance(base, Resolution): + return base + parsed_base = urlsplit(base) + if parsed_base.scheme == "qrc": + combined = "qrc:" + posixpath.join(posixpath.dirname(parsed_base.path), parsed.path) + elif parsed_base.scheme == "file": + combined = urljoin(base, literal_url) + else: + return Resolution("unsupported", reason="unsupported_url_base") + return normalize_url(combined) + + +class QtResourceIndex: + def __init__(self, nodes, *, root: Path): + self.root = Path(root).resolve() + self.nodes, self.by_file, self.components, self.aliases = {}, {}, {}, {} + for node in nodes: + path = source_path(node, self.root) + if path is None or not node.get("id"): + continue + nid, md = node["id"], qml_metadata(node) + self.nodes[nid] = node + self.by_file.setdefault(path, []).append(nid) + if md.get("kind") == "component": + self.components.setdefault(path, []).append(nid) + elif md.get("kind") == "resource_alias": + self.aliases.setdefault(md.get("logical_url"), []).append(nid) + + def _file(self, path: str, *, evidence=()) -> Resolution: + ids = self.components.get(path) + if ids is None: + ids = [nid for nid in self.by_file.get(path, []) if self.nodes[nid].get("type") == "file"] + return answer(ids, reason="resource_target_outside_corpus", evidence=evidence) + + def _resource(self, literal: str) -> Resolution: + ids = self.aliases.get(literal, []) + if not ids: + return Resolution("unavailable", reason="resource_alias_unavailable") + if any(qml_metadata(self.nodes[nid]).get("locale") for nid in ids): + return Resolution("unsupported", reason="resource_locale_dependent", evidence=tuple(ids[:50])) + results = [self._file(qml_metadata(self.nodes[nid]).get("target_path", ""), evidence=(nid,)) + for nid in ids] + if any(result.target_id is None for result in results): + return Resolution("unavailable", reason="resource_target_outside_corpus", evidence=tuple(ids[:50])) + # Duplicate mappings remain ambiguous even when their targets coincide. + if len(ids) != 1: + return Resolution("ambiguous", reason="duplicate_resource_alias", + candidates=tuple(sorted({result.target_id for result in results + if result.target_id is not None})[:50]), + evidence=tuple(ids[:50])) + return results[0] + + def resolve_url(self, literal_url: str, base_url: str | None = None) -> Resolution: + """Resolve explicit qrc/file URLs or relative URLs with an explicit URL base.""" + normalized = normalize_url(literal_url, base_url) + if isinstance(normalized, Resolution): + return normalized + parsed = urlsplit(normalized) + path = unquote(parsed.path) + if parsed.scheme == "qrc": + return self._resource(normalized) + if parsed.scheme == "file": + local = Path(path[1:] if len(path) > 3 and path[0] == "/" and path[2] == ":" else path) + if not local.is_absolute(): + return Resolution("unsupported", reason="file_url_not_absolute") + try: + accepted = local.resolve().relative_to(self.root).as_posix() + except ValueError: + return Resolution("unavailable", reason="file_url_outside_root") + return self._file(accepted) + return Resolution("unsupported", reason="unsupported_url_scheme") diff --git a/graphify/qt_source_containment.py b/graphify/qt_source_containment.py new file mode 100644 index 0000000000..ffd51c53d2 --- /dev/null +++ b/graphify/qt_source_containment.py @@ -0,0 +1,49 @@ +"""Connect observed Qt sites to accepted source files without inventing owners.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import encode_qt, qt_metadata +from graphify.qml_resolution_types import source_path +from graphify.qt_source_file_role import generic_file_role + + +def attach_qt_file_sites(results, nodes, edges, *, root, fresh_ast_ids=()): + """Use existing canonical file IDs; borrowed semantic metadata stays unchanged. + + The generic producer's file role is its AST origin, code type, L1 location, + exact filename label and absence of callable/semantic roles. Bounded C++ file + transport is accepted without granting constructor/type authority. Containment + is independent of an unavailable callable, class or runtime target. Duplicate + or foreign file evidence cannot authorize a guessed container. + """ + # Fresh canonical AST nodes precede the facade's final provenance tagging. + # Borrowed context needs its persisted marker; absence alone is no authority. + fresh_ast_ids = set(fresh_ast_ids) + files = {} + for node in nodes: + path = source_path(node, root) + if generic_file_role(node, path, fresh_ast_ids): + files.setdefault(path, []).append(node["id"]) + existing = {(edge.get("source"), edge.get("target"), edge.get("context")) for edge in edges} + added = [] + for result in results.values(): + for site in result.get("nodes", []): + metadata = qt_metadata(site) + if not metadata or metadata.get("owner_id"): + continue + owners = set(files.get(source_path(site, root), [])) + if len(owners) != 1: + continue + owner = next(iter(owners)) + key = (owner, site["id"], "qt_source_file") + if key in existing: + continue + # Preserve the occurrence's original bytes and endpoint direction; + # owner_id remains unavailable rather than being changed to a file. + edge = {"source": owner, "target": site["id"], "_src": owner, "_tgt": site["id"], + "relation": "contains", "context": "qt_source_file", "confidence": "EXTRACTED", + "source_file": site["source_file"], "source_location": site["source_location"], + "metadata": {"qt": encode_qt({"span": metadata.get("span", {}), "target_id": site["id"]})}} + result.setdefault("edges", []).append(edge) + added.append(edge) + existing.add(key) + return added diff --git a/graphify/qt_source_file_role.py b/graphify/qt_source_file_role.py new file mode 100644 index 0000000000..817f91ae8c --- /dev/null +++ b/graphify/qt_source_file_role.py @@ -0,0 +1,32 @@ +"""Accepted generic file roles shared by Qt membership and source containment.""" +from __future__ import annotations + +from pathlib import PurePosixPath + +from graphify.extractors.cpp_constructor_type_authority import valid_source_transport + + +def generic_file_role(node, path, fresh_ast_ids=()): + """Transport augments source proof without becoming callable/type authority. + + C++ file records now carry bounded constructor include/shadow evidence. Only + that exact producer contract is accepted alongside the legacy empty record; + unrelated metadata cannot turn a semantic node into a file. The caller owns + source-path normalization and uniqueness; this predicate never reads source. + """ + origin = node.get("_origin") + if (not path or not (origin == "ast" or origin is None and node.get("id") in fresh_ast_ids) + or node.get("file_type") != "code" or node.get("source_location") != "L1" + or node.get("label") != PurePosixPath(path).name + or node.get("type") not in {None, "file"} + or node.get("_callable") or node.get("_callable_class")): + return False + metadata = node.get("metadata") + if metadata is None or metadata == {}: + return True + if not isinstance(metadata, dict) or set(metadata) != {"cpp_constructor_types"}: + return False + record = metadata["cpp_constructor_types"] + return (isinstance(record, dict) + and set(record) == {"contract_version", "complete", "source_size", "includes", "shadows"} + and valid_source_transport(record)) diff --git a/graphify/report.py b/graphify/report.py index 168197d54e..d3493506c6 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -205,6 +205,11 @@ def _real_count(nodes) -> int: f"- Token cost: {token_cost.get('input', 0):,} input · {token_cost.get('output', 0):,} output", ] + # Accepted source facts expose static resolution gaps independently of the + # edge-confidence summary; a valid graph cannot certify Qt runtime behavior. + from graphify.qt_coverage import coverage_lines + lines.extend(coverage_lines(G)) + if built_at_commit: lines += [ "", diff --git a/graphify/serve.py b/graphify/serve.py index c97da5ed0b..80c6be8a4d 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -341,7 +341,8 @@ def _flatten_attr_text(val, parts: list[str]) -> None: def _node_attributes_text(data: dict) -> tuple[str, str]: """The node's `attributes` key-value pairs normalized and tokenized for search.""" - attrs = data.get("attributes") + from graphify.qt_qml_search import search_attributes + attrs = search_attributes(data) if not isinstance(attrs, dict) or not attrs: return "", "" parts: list[str] = [] @@ -749,6 +750,9 @@ def _pick_scored_endpoint(G: nx.Graph, scored: list[tuple[float, str]], query: s `scored` must be non-empty (both callers return early on no match). """ + # A public node ID is an explicit identity request, not a fuzzy label query. + if query in G: + return query qtokens = set(_search_tokens(query)) if not qtokens: return scored[0][1] @@ -1173,6 +1177,7 @@ def _adj(n): f"{attrs_suffix}]" ) lines.append(line) + render_edges = [] for u, v in edges: if u in nodes and v in nodes: raw = G[u][v] @@ -1191,23 +1196,30 @@ def _adj(n): # would KeyError on an unknown id (#2080 review). if {src, tgt} != {u, v}: src, tgt = u, v - context = d.get("context") - context_suffix = f" context={sanitize_label(str(context))}" if context else "" - # The relation SITE (call/import/reference line in the source's - # file), not a def line — so "who calls X" cites a clickable call - # location, not the caller's def (#BUG1). - _loc = str(d.get("source_location") or "") - at_suffix = ( - f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(_loc)}" - if _loc else "" - ) - line = ( - f"EDGE {sanitize_label(G.nodes[src].get('label', src))} " - f"--{sanitize_label(str(d.get('relation', '')))} " - f"[{sanitize_label(str(d.get('confidence', '')))}{context_suffix}]--> " - f"{sanitize_label(G.nodes[tgt].get('label', tgt))}{at_suffix}" - ) - lines.append(line) + render_edges.append((src, tgt, d)) + # Traversal completion walks a set of visited nodes. Render source facts in + # stable semantic order so CLI and an independent MCP process agree. + render_edges.sort(key=lambda item: tuple(str(value or "") for value in ( + item[0], item[1], *(item[2].get(key) for key in ( + "relation", "context", "confidence", "source_file", "source_location"))))) + for src, tgt, d in render_edges: + context = d.get("context") + context_suffix = f" context={sanitize_label(str(context))}" if context else "" + # The relation SITE (call/import/reference line in the source's + # file), not a def line — so "who calls X" cites a clickable call + # location, not the caller's def (#BUG1). + _loc = str(d.get("source_location") or "") + at_suffix = ( + f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(_loc)}" + if _loc else "" + ) + line = ( + f"EDGE {sanitize_label(G.nodes[src].get('label', src))} " + f"--{sanitize_label(str(d.get('relation', '')))} " + f"[{sanitize_label(str(d.get('confidence', '')))}{context_suffix}]--> " + f"{sanitize_label(G.nodes[tgt].get('label', tgt))}{at_suffix}" + ) + lines.append(line) output = "\n".join(lines) if len(output) > char_budget: cut_at = output[:char_budget].rfind("\n") @@ -1815,6 +1827,7 @@ def _shortest_path_text(G: nx.Graph, arguments: dict) -> str: if hops > max_hops: return f"Path exceeds max_hops={max_hops} ({hops} hops found)." segments = [] + path_edges = [] for i in range(len(path_nodes) - 1): u, v = path_nodes[i], path_nodes[i + 1] # Report the actual stored relation(s), never a fabricated `calls`; @@ -1828,6 +1841,7 @@ def _shortest_path_text(G: nx.Graph, arguments: dict) -> str: for d in edge_datas(G, a, b): (fwd if d.get("_src", a) == u else bwd).append(d) datas = fwd or bwd + path_edges.extend(datas) forward = bool(fwd) rels = sorted({d.get("relation") for d in datas if d.get("relation")}) rel = "/".join(rels) if rels else "related" @@ -1840,7 +1854,9 @@ def _shortest_path_text(G: nx.Graph, arguments: dict) -> str: else: segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}") prefix = ("\n".join(warnings) + "\n") if warnings else "" - return prefix + f"Shortest path ({hops} hops):\n " + " ".join(segments) + from graphify.path_provenance import path_provenance + sources = path_provenance([G.nodes[node_id] for node_id in path_nodes], path_edges) + return prefix + f"Shortest path ({hops} hops):\n " + " ".join(segments) + sources def _filter_blank_stdin() -> None: diff --git a/graphify/skill-agents.md b/graphify/skill-agents.md index 3f7fade819..87d10b0802 100644 --- a/graphify/skill-agents.md +++ b/graphify/skill-agents.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-aider.md b/graphify/skill-aider.md index 6e459945af..f873f01a60 100644 --- a/graphify/skill-aider.md +++ b/graphify/skill-aider.md @@ -195,10 +195,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -208,7 +216,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('.graphify_ast.json').write_text(json.dumps(result, indent=2)) print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: @@ -766,6 +779,10 @@ The graph is the map. Your job after the pipeline is to be the guide. Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skill-amp.md b/graphify/skill-amp.md index 3f7fade819..87d10b0802 100644 --- a/graphify/skill-amp.md +++ b/graphify/skill-amp.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md index 2eca2a295f..1f4d24162f 100644 --- a/graphify/skill-claw.md +++ b/graphify/skill-claw.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md index a126527fd7..d38dec09e9 100644 --- a/graphify/skill-codex.md +++ b/graphify/skill-codex.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-copilot.md b/graphify/skill-copilot.md index 2eca2a295f..1f4d24162f 100644 --- a/graphify/skill-copilot.md +++ b/graphify/skill-copilot.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-devin.md b/graphify/skill-devin.md index 9bd1f0dc73..ac25fa6b6e 100644 --- a/graphify/skill-devin.md +++ b/graphify/skill-devin.md @@ -208,10 +208,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -221,7 +229,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2)) print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: @@ -905,6 +918,10 @@ fi Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md index b1eeb4925a..df8b4bb67d 100644 --- a/graphify/skill-droid.md +++ b/graphify/skill-droid.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-kilo.md b/graphify/skill-kilo.md index 387563de43..cb4c434985 100644 --- a/graphify/skill-kilo.md +++ b/graphify/skill-kilo.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-kiro.md b/graphify/skill-kiro.md index 2eca2a295f..1f4d24162f 100644 --- a/graphify/skill-kiro.md +++ b/graphify/skill-kiro.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md index 144378e65a..03baadef09 100644 --- a/graphify/skill-opencode.md +++ b/graphify/skill-opencode.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-pi.md b/graphify/skill-pi.md index 2eca2a295f..1f4d24162f 100644 --- a/graphify/skill-pi.md +++ b/graphify/skill-pi.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md index 36706e3d7c..51273ca71c 100644 --- a/graphify/skill-trae.md +++ b/graphify/skill-trae.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md index 6191a4ca6d..00a2ba94cc 100644 --- a/graphify/skill-vscode.md +++ b/graphify/skill-vscode.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index 456244a246..64bac5060c 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -205,10 +205,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```powershell @' import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -218,7 +226,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8") print(f'AST: {len(result["nodes"])} nodes, {len(result["edges"])} edges') else: diff --git a/graphify/skill.md b/graphify/skill.md index 2eca2a295f..1f4d24162f 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/graphify/skills/agents/references/extraction-spec.md b/graphify/skills/agents/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/agents/references/extraction-spec.md +++ b/graphify/skills/agents/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/agents/references/update.md b/graphify/skills/agents/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/agents/references/update.md +++ b/graphify/skills/agents/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/amp/references/extraction-spec.md b/graphify/skills/amp/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/amp/references/extraction-spec.md +++ b/graphify/skills/amp/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/amp/references/update.md b/graphify/skills/amp/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/amp/references/update.md +++ b/graphify/skills/amp/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/claude/references/extraction-spec.md b/graphify/skills/claude/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/claude/references/extraction-spec.md +++ b/graphify/skills/claude/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/claude/references/update.md b/graphify/skills/claude/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/claude/references/update.md +++ b/graphify/skills/claude/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/claw/references/extraction-spec.md b/graphify/skills/claw/references/extraction-spec.md index 4b278b28d3..0398cfeeee 100644 --- a/graphify/skills/claw/references/extraction-spec.md +++ b/graphify/skills/claw/references/extraction-spec.md @@ -14,6 +14,7 @@ Rules: - INFERRED: reasonable inference (shared structure, implied dependency) - AMBIGUOUS: uncertain — flag it, do not omit - Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language. +- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls. - Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. - Image files: use vision — understand what the image IS, not just OCR - DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. @@ -24,6 +25,8 @@ Rules: Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Output exactly this JSON (no other text): {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/claw/references/update.md b/graphify/skills/claw/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/claw/references/update.md +++ b/graphify/skills/claw/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/codex/references/extraction-spec.md b/graphify/skills/codex/references/extraction-spec.md index 4b278b28d3..0398cfeeee 100644 --- a/graphify/skills/codex/references/extraction-spec.md +++ b/graphify/skills/codex/references/extraction-spec.md @@ -14,6 +14,7 @@ Rules: - INFERRED: reasonable inference (shared structure, implied dependency) - AMBIGUOUS: uncertain — flag it, do not omit - Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language. +- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls. - Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. - Image files: use vision — understand what the image IS, not just OCR - DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. @@ -24,6 +25,8 @@ Rules: Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Output exactly this JSON (no other text): {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/codex/references/update.md b/graphify/skills/codex/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/codex/references/update.md +++ b/graphify/skills/codex/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/copilot/references/extraction-spec.md b/graphify/skills/copilot/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/copilot/references/extraction-spec.md +++ b/graphify/skills/copilot/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/copilot/references/update.md b/graphify/skills/copilot/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/copilot/references/update.md +++ b/graphify/skills/copilot/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/droid/references/extraction-spec.md b/graphify/skills/droid/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/droid/references/extraction-spec.md +++ b/graphify/skills/droid/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/droid/references/update.md b/graphify/skills/droid/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/droid/references/update.md +++ b/graphify/skills/droid/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/kilo/references/extraction-spec.md b/graphify/skills/kilo/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/kilo/references/extraction-spec.md +++ b/graphify/skills/kilo/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/kilo/references/update.md b/graphify/skills/kilo/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/kilo/references/update.md +++ b/graphify/skills/kilo/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/kiro/references/extraction-spec.md b/graphify/skills/kiro/references/extraction-spec.md index 4b278b28d3..0398cfeeee 100644 --- a/graphify/skills/kiro/references/extraction-spec.md +++ b/graphify/skills/kiro/references/extraction-spec.md @@ -14,6 +14,7 @@ Rules: - INFERRED: reasonable inference (shared structure, implied dependency) - AMBIGUOUS: uncertain — flag it, do not omit - Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language. +- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls. - Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. - Image files: use vision — understand what the image IS, not just OCR - DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. @@ -24,6 +25,8 @@ Rules: Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Output exactly this JSON (no other text): {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/kiro/references/update.md b/graphify/skills/kiro/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/kiro/references/update.md +++ b/graphify/skills/kiro/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/opencode/references/extraction-spec.md b/graphify/skills/opencode/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/opencode/references/extraction-spec.md +++ b/graphify/skills/opencode/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/opencode/references/update.md b/graphify/skills/opencode/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/opencode/references/update.md +++ b/graphify/skills/opencode/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/pi/references/extraction-spec.md b/graphify/skills/pi/references/extraction-spec.md index 4b278b28d3..0398cfeeee 100644 --- a/graphify/skills/pi/references/extraction-spec.md +++ b/graphify/skills/pi/references/extraction-spec.md @@ -14,6 +14,7 @@ Rules: - INFERRED: reasonable inference (shared structure, implied dependency) - AMBIGUOUS: uncertain — flag it, do not omit - Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language. +- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls. - Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. - Image files: use vision — understand what the image IS, not just OCR - DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. @@ -24,6 +25,8 @@ Rules: Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Output exactly this JSON (no other text): {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/pi/references/update.md b/graphify/skills/pi/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/pi/references/update.md +++ b/graphify/skills/pi/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/trae/references/extraction-spec.md b/graphify/skills/trae/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/trae/references/extraction-spec.md +++ b/graphify/skills/trae/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/trae/references/update.md b/graphify/skills/trae/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/trae/references/update.md +++ b/graphify/skills/trae/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/vscode/references/extraction-spec.md b/graphify/skills/vscode/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/vscode/references/extraction-spec.md +++ b/graphify/skills/vscode/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/vscode/references/update.md b/graphify/skills/vscode/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/vscode/references/update.md +++ b/graphify/skills/vscode/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/skills/windows/references/extraction-spec.md b/graphify/skills/windows/references/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/graphify/skills/windows/references/extraction-spec.md +++ b/graphify/skills/windows/references/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/graphify/skills/windows/references/update.md b/graphify/skills/windows/references/update.md index 3632fd4126..a917e2224b 100644 --- a/graphify/skills/windows/references/update.md +++ b/graphify/skills/windows/references/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/graphify/source_identity.py b/graphify/source_identity.py new file mode 100644 index 0000000000..8586f6c0c4 --- /dev/null +++ b/graphify/source_identity.py @@ -0,0 +1,125 @@ +"""Stateless source spelling, walked naming, and shared physical ID ownership. + +Native input spelling is admitted before extraction. After physical containment, +walked aliases retain their lexical owner, and shared resolved forms receive an +explicit owner without relying on batch order. The caller owns admission scope, +the per-run realpath cache, source reads, endpoint creation, and publication. +""" +from __future__ import annotations + +from collections.abc import Callable +import ctypes +import json +import os +from pathlib import Path +import stat + + +NATIVE_PATH_CAPACITY = 32768 +SOURCE_CONTEXT_LIMIT = 160 + + +def _source_context(path: Path, root: Path | None) -> str: + """Use only lexical context; failed admission cannot authorize another lookup.""" + if root is None: + return "" + try: + relative = Path(os.path.abspath(path)).relative_to(Path(os.path.abspath(root))).as_posix() + except (OSError, RuntimeError, ValueError): + return "" + if len(relative) > SOURCE_CONTEXT_LIMIT or any( + ord(char) < 32 or 127 <= ord(char) < 160 or char in "\u2028\u2029" + for char in relative): + return "" + return relative + + +def _native_long_name(path: Path) -> str: + """Expand NTFS entry spellings without resolving lexical junction owners.""" + api = ctypes.windll.kernel32.GetLongPathNameW + api.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32) + api.restype = ctypes.c_uint32 + buffer = ctypes.create_unicode_buffer(NATIVE_PATH_CAPACITY) + length = api(str(path), buffer, len(buffer)) + value = buffer.value + # Win32 reports UTF-16 code units; a supplementary character occupies two. + units = len(value.encode("utf-16-le", errors="surrogatepass")) // 2 + if not length or length >= len(buffer) or not value or units != length: + raise ValueError("invalid native spelling result") + return value + + +def normalize_input_source(path: Path, root: Path | None = None) -> Path: + """Admit one native entry spelling before dispatch, facts, or cache ownership. + +Missing/non-file inputs retain their existing downstream handling. Existing +Windows files must expand to the same physical target; the lexical returned +name preserves separate discovered symlink/junction owners. No content is read. +""" + if os.name != "nt": + return path + try: + try: + mode = path.stat().st_mode + except (FileNotFoundError, NotADirectoryError): + return path + if not stat.S_ISREG(mode): + return path + physical = path.resolve(strict=True) + expanded = Path(_native_long_name(path)) + if expanded.resolve(strict=True) != physical or not expanded.samefile(path): + raise ValueError("native spelling changed physical input") + return expanded + except (OSError, RuntimeError, ValueError, AttributeError, ctypes.ArgumentError): + source = json.dumps(_source_context(path, root), ensure_ascii=True) + raise ValueError(f"SOURCE_INPUT_IDENTITY_FAILED: source={source}; native source " + "spelling unavailable; repair filesystem access and retry") from None + + +def resolved_source_owners(paths: list[Path], root: Path, *, + walked_relative: Callable[[Path, Path], Path], + realpath: Callable[[str, str], Path], cwd: str) -> dict[Path, Path]: + """Choose shared physical ID ownership independently of input ordering. + +Every walked source keeps its own primary identity. A supplied physical owner +owns its resolved spelling; one alias preserves the historical unique fallback. +Multiple aliases without that owner cannot authorize choosing one arbitrarily: +their shared target retains its portable physical ID without minting a node. +""" + claims: dict[Path, set[Path]] = {} + for path in paths: + physical = realpath(str(path), cwd) + try: + relative = walked_relative(path, physical) + except ValueError: + continue + claims.setdefault(physical, set()).add(relative) + owners = {} + for physical, relatives in claims.items(): + canonical = physical.relative_to(root) + owners[physical] = (next(iter(relatives)) if len(relatives) == 1 + else canonical) + if canonical in relatives: + owners[physical] = canonical + return owners + + +def walked_relative_source(path: Path, root: Path, resolved: Path, *, + realpath: Callable[[str, str], Path], cwd: str) -> Path: + """Reject foreign physical targets before retaining any written alias name. + +Inputs are absolute source/root paths; root is the caller's canonical root. +No root/index/cache state is retained here and no accepted endpoint is minted. +The fallback handles platform aliases without a corresponding lexical ancestor. +""" + relative = resolved.relative_to(root) + try: + return path.relative_to(root) + except ValueError: + for parent in path.parents: + try: + if realpath(str(parent), cwd) == root: + return path.relative_to(parent) + except (OSError, RuntimeError): + continue + return relative diff --git a/graphify/watch.py b/graphify/watch.py index 59613b6275..15d9f910e4 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -18,6 +18,7 @@ os_replace_with_fallback, write_text_atomic, ) +from graphify.publication import ProductPublication logger = logging.getLogger(__name__) _PENDING_FILENAME = ".pending_changes" @@ -825,6 +826,8 @@ def _reconcile_existing_graph( deleted_source_identities: set[str], is_ignored_always: Callable[[Path], bool] | None = None, is_ignored_full: Callable[[Path], bool] | None = None, + preserve_sourceless_ast: bool = False, + prior_import_ids: set[str] | None = None, ) -> tuple[dict, dict]: """Merge fresh extraction with preserved graph entries and evict stale sources. @@ -836,6 +839,10 @@ def _reconcile_existing_graph( .gitignore-driven rules, honored only on a full rebuild (an explicit ``graphify update``) so the incremental/hook path keeps preserving a deliberately-graphed .gitignore'd tree (#1795). + + ``prior_import_ids`` is caller-owned cleanup proof. It receives only + persisted source-owned imports before legacy provenance inference; inferred + origins are compatibility annotations and cannot authorize removal. """ existing_graph_data: dict = {} if not existing_graph.exists(): @@ -859,6 +866,13 @@ def _reconcile_existing_graph( existing = json.loads(existing_graph.read_text(encoding="utf-8")) existing_graph_data = existing + # Capture cleanup authority from persisted producer facts before legacy + # origin inference can turn an unmarked import into accepted AST evidence. + if prior_import_ids is not None: + from graphify.build import _EXTERNAL_STUB_RELATIONS + from graphify.qt_orphan_cleanup import previous_import_targets + prior_import_ids.update(previous_import_targets(existing, import_relations=_EXTERNAL_STUB_RELATIONS)) + # Backfill tier provenance on legacy items (#2334), mirroring # build._load_existing_graph (this reconcile path loads the raw dict # separately, so the backfill there does not reach it). Stamping preserved @@ -1024,6 +1038,7 @@ def _ignored_now(identity: str) -> bool: and ( ( not node.get("source_file") + and not preserve_sourceless_ast and (full_rebuild or not code_files) ) or ( @@ -1132,9 +1147,27 @@ def _node_community_map(graph_data: dict) -> dict[str, int]: return out +def _valid_publication_direction(edges, *, directed) -> bool: + """Reject corrupt transport before shortcuts can strip markers or accept checkpoints.""" + from graphify.graph_direction import valid_direction_pairs + if valid_direction_pairs(edges, directed=directed): + return True + print("[graphify watch] error: QT_EXPORT_DIRECTION: invalid logical edge pair; prior products retained. " + "Re-extract or repair the graph and retry.", file=sys.stderr) + return False + + def _canonical_graph_for_compare(graph_data: dict) -> dict: canonical = dict(graph_data) canonical.pop("built_at_commit", None) + # Initial extraction and incremental reconciliation omit different empty + # run lists. Only their absent/empty forms are equivalent; nonempty values, + # malformed transport and unknown metadata remain observable differences. + for key in ("diagnostics", "failed_sources", "qml_failures", "qt_failures", "hyperedges"): + canonical.setdefault(key, []) + # This list describes the current extraction run, not persistent source + # facts. Nodes, edges and their authoritative source proof remain compared. + canonical.pop("extracted_sources", None) # A missing "directed" key means the same thing as "directed": false # everywhere else in the codebase (#2342's --no-cluster path only started # writing the key once it began inheriting it from the existing graph). @@ -1312,12 +1345,19 @@ def _stabilize_rebuild_cwd(watch_path: Path) -> bool: return True repo_root = os.environ.get("GRAPHIFY_REPO_ROOT", "").strip() + # Keep recovery order unchanged, but retain the supplied root's safe failure + # category if CWD lookup also fails. Raw paths and OS exception bodies may + # contain private repository details and do not belong in this diagnostic. + root_failure = ( + "GRAPHIFY_REPO_ROOT does not name an available directory." + if repo_root else "GRAPHIFY_REPO_ROOT is not set." + ) if repo_root and Path(repo_root).is_dir(): try: os.chdir(repo_root) return True except OSError: - pass + root_failure = "GRAPHIFY_REPO_ROOT could not be entered." try: Path.cwd() @@ -1325,7 +1365,7 @@ def _stabilize_rebuild_cwd(watch_path: Path) -> bool: except FileNotFoundError: print( "[graphify watch] Rebuild failed: current working directory " - "no longer exists and GRAPHIFY_REPO_ROOT is not set." + f"no longer exists and {root_failure}" ) return False @@ -1528,6 +1568,17 @@ def _rebuild_code( gitignore=_gitignore_enabled, ) code_files = [Path(f) for f in detected['files']['code']] + from graphify.qt_analysis_state import inspect_qt_analysis, commit_qt_analysis + qt_state = inspect_qt_analysis(watch_root, out, code_files, + excludes=_persisted_excludes, gitignore=_gitignore_enabled) + qt_full_refresh = qt_state.has_qt and qt_state.changed + + from graphify.qml_safety import ( + qml_refresh_required, require_complete_qml, require_qml_watch_root, + ) + # The legacy subfolder rebase changes paths but not nested scoped IDs. + # Refuse before extraction/reconciliation rather than publish mixed roots. + require_qml_watch_root(code_files, project_root=project_root, watch_root=watch_root) # #3511: `graphify extract` has surfaced files it saw but could not # classify since #1692; this update/watch rebuild path never did, @@ -1697,9 +1748,18 @@ def _add_deleted_source(path: Path) -> None: # File was deleted or renamed away inside the watched root. # Evict preserved nodes that still claim this source path. _add_deleted_source(deleted_in_root) + # Shared physical bytes can change every already admitted lexical + # owner. Keep semantic documents and discovery outside this boundary. + from graphify.watch_coowners import expand_changed_coowners + wanted = expand_changed_coowners(wanted, (p for p in code_files if p not in semantic_doc_files), root=watch_root) from graphify.extractors.terraform import refresh_terraform_paths wanted = refresh_terraform_paths(wanted, code_files, changed_paths) - if not wanted and not deleted_paths: + # Changes to QML/metadata/embedded JS may invalidate unchanged QML + # references. A complete live-code refresh is the safe initial policy. + if qt_full_refresh or qml_refresh_required(code_files, changed_paths): + wanted = [p for p in code_files if p not in semantic_doc_files] + print("[graphify watch] Qt/QML inputs changed; refreshing the live code corpus.") + if not wanted and not deleted_paths and not qt_full_refresh: print("[graphify watch] No tracked code files in change set - skipping rebuild.") return True extract_targets = wanted @@ -1849,12 +1909,22 @@ def _add_deleted_source(path: Path) -> None: result = extract( extract_targets, cache_root=watch_root, + qml_import_roots=qt_state.import_roots, + refresh_native=qt_state.has_qt, resolution_context_nodes=resolution_context_nodes or None, resolution_context_edges=resolution_context_edges or None, ) if extract_targets else { "nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0, } + # The count-based shrink guard cannot account for edge loss or unrelated + # additions. QML failure rejects the candidate before any reconciliation. + require_complete_qml(result, extract_targets, operation="update/watch", root=watch_root) + if project_root != watch_root and any( + node.get("metadata", {}).get("qt", {}).get("contract_version") == 1 + for node in result.get("nodes", []) + ): + raise ValueError("QML_ROOT_MISMATCH: native Qt scoped facts require the absolute project scan root; prior graph and manifest were preserved") _rebase_relative_source_files(result, watch_root, project_root) # #2543: AST sources that failed this run (error result, or extractor @@ -1863,6 +1933,14 @@ def _add_deleted_source(path: Path) -> None: # gate reports them unchanged forever and only deleting graphify-out/ # recovers. Mirrors the extract CLI's _stamped_manifest_files handling. _failed_ast_sources = set(result.get("failed_sources") or []) + # A complete accepted Qt refresh may retire stale generic placeholders + # only after reconciliation exposes every surviving edge/hyperedge. + fresh_ast_ids = {node["id"] for node in result.get("nodes", [])} + complete_qt_refresh = bool( + qt_state.has_qt and not _failed_ast_sources + and {Path(path).resolve() for path in extract_targets} + == {Path(path).resolve() for path in code_files if path not in semantic_doc_files} + ) def _ast_manifest_files() -> dict[str, list[str]]: """detected["files"] minus this run's failed AST sources (#2543). @@ -1892,6 +1970,22 @@ def _failed(f: str) -> bool: for ftype, flist in detected["files"].items() } + def _stage_analysis_products(publication: ProductPublication) -> None: + """Prepare existing serializers against old seeded bytes, never accepted paths.""" + from graphify.detect import save_manifest + publication.stage(".graphify_root").write_text( + _graphify_root_marker_value(watch_path), encoding="utf-8") + save_manifest( + _ast_manifest_files(), manifest_path=str(publication.stage("manifest.json")), + kind="ast", root=watch_root, + scan_corpus={f for files in detected["files"].values() for f in files}, + clear_ast=_failed_ast_sources or None, + ) + if not _failed_ast_sources: + commit_qt_analysis(publication.stage(".qt_analysis.json").parent, qt_state) + + accepted_products = ("graph.json", ".graphify_root", "manifest.json", ".qt_analysis.json") + # Preserve semantic nodes/edges from a previous full run. # AST-only rebuild replaces nodes for changed files; everything else is kept. # Filter by node ID membership in the new AST output, not by file_type — @@ -1900,6 +1994,7 @@ def _failed(f: str) -> bool: # When the caller supplied changed_paths, also evict preserved nodes whose # source_file matches a path that was changed (re-extracted) or deleted — # otherwise the old nodes for those files would survive forever. + prior_import_ids: set[str] = set() try: result, existing_graph_data = _reconcile_existing_graph( existing_graph, @@ -1909,11 +2004,13 @@ def _failed(f: str) -> bool: watch_root=watch_root, code_files=code_files, extract_targets=extract_targets, - full_rebuild=changed_paths is None, + full_rebuild=changed_paths is None or qt_full_refresh, deleted_paths=deleted_paths, deleted_source_identities=deleted_source_identities, is_ignored_always=_ignored_always, is_ignored_full=_ignored_full, + preserve_sourceless_ast=complete_qt_refresh, + prior_import_ids=prior_import_ids if complete_qt_refresh else None, ) except (RuntimeError, ValueError) as exc: # Existing graph present but unreadable — over the size cap @@ -1926,6 +2023,11 @@ def _failed(f: str) -> bool: print(f"error: {exc}", file=sys.stderr) return False + from graphify.qt_orphan_cleanup import prune_stale_ast_orphans + result = prune_stale_ast_orphans( + result, fresh_ids=fresh_ast_ids, complete_refresh=complete_qt_refresh, + prior_import_ids=prior_import_ids) + _relativize_source_files(result, project_root, scope=watch_root) # Source files re-extracted this run — their symbol sets may legitimately # shrink (a removed function), so the shrink-guard should not block the @@ -1972,7 +2074,17 @@ def _failed(f: str) -> bool: # an import to stdlib / a third-party module leaves an undeclared # endpoint in graph.json that every loader materialises as an # attribute-less phantom (#2873). + # Publication-created stubs are semantic placeholders from their + # first write, matching reconciliation without retagging authored + # or source-backed nodes already present in the merged extraction. + prior_node_count = len(candidate_graph_data["nodes"]) _mint_external_stubs_in_data(candidate_graph_data) + for stub in candidate_graph_data["nodes"][prior_node_count:]: + stub.setdefault("_origin", "semantic") + if not _valid_publication_direction( + ((edge["source"], edge["target"], edge) for edge in candidate_graph_data["links"]), + directed=candidate_graph_data["directed"]): + return False candidate_graph_text = _json_text(candidate_graph_data) same_graph = False if existing_graph.exists(): @@ -2008,41 +2120,14 @@ def _failed(f: str) -> bool: return False from graphify.export import backup_if_protected as _backup _backup(out) - # Atomic replace via tmp file, matching the clustered path: a - # crash mid-write must not leave a truncated graph.json. - # os_replace_with_fallback, not a plain Path.replace (#2689): - # this function just read existing_graph a few lines up, and - # on a VMware HGFS shared folder a replace over a destination - # read earlier in the same process raises PermissionError - # even on the same drive. - graph_tmp = out / ".graph.tmp.json" - graph_tmp.write_text(candidate_graph_text, encoding="utf-8") - os_replace_with_fallback(graph_tmp, existing_graph) - - # Write the scan root only after the candidate graph is accepted, - # so a refused shrink cannot mismatch graph and marker. See - # _graphify_root_marker_value for why this isn't always the raw - # caller-supplied value (#3375). - (out / ".graphify_root").write_text( - _graphify_root_marker_value(watch_path), encoding="utf-8" - ) - - try: - from graphify.detect import save_manifest - # detected["files"] is a FULL detect of the watched root, so - # pass it as the scan corpus too: rows for files that left the - # scan but still exist on disk (newly excluded) are pruned - # instead of surviving as phantom "deleted" entries (#1908). - # Failed AST sources are dropped from the stamped set and - # their prior hashes blanked (#2543). - save_manifest( - _ast_manifest_files(), manifest_path=str(out / "manifest.json"), - kind="ast", root=watch_root, - scan_corpus={f for _fl in detected["files"].values() for f in _fl}, - clear_ast=_failed_ast_sources or None, - ) - except Exception: - pass + # Prepare all acceptance products before the first replacement; + # an exception restores their old bytes rather than accepting a + # graph whose manifest or Qt fingerprint was never committed. + with ProductPublication(out, accepted_products, replace=os_replace_with_fallback) as publication: + if not same_graph: + publication.stage("graph.json").write_text(candidate_graph_text, encoding="utf-8") + _stage_analysis_products(publication) + publication.commit() if same_graph: print("[graphify watch] No code-graph changes detected (--no-cluster); outputs left untouched.") @@ -2066,6 +2151,8 @@ def _failed(f: str) -> bool: # update` can't silently downgrade a directed graph to undirected - # build_from_json defaults to directed=False otherwise. G = build_from_json(result, directed=bool((existing_graph_data or {}).get("directed", False))) + if not _valid_publication_direction(G.edges(data=True), directed=G.is_directed()): + return False candidate_topology = _topology_from_graph(G) if existing_graph_data: try: @@ -2076,18 +2163,9 @@ def _failed(f: str) -> bool: except Exception: same_topology = False if same_topology: - try: - from graphify.detect import save_manifest - # Full-scan save: prune excluded-but-alive rows (#1908); - # leave failed AST sources unstamped (#2543). - save_manifest( - _ast_manifest_files(), manifest_path=str(out / "manifest.json"), - kind="ast", root=watch_root, - scan_corpus={f for _fl in detected["files"].values() for f in _fl}, - clear_ast=_failed_ast_sources or None, - ) - except Exception: - pass + with ProductPublication(out, accepted_products, replace=os_replace_with_fallback) as publication: + _stage_analysis_products(publication) + publication.commit() html_action = _reconcile_graph_html(out, existing_graph_data) if html_action == "rendered": print( @@ -2211,7 +2289,6 @@ def _failed(f: str) -> bool: no_change = same_graph and same_report if no_change: graph_tmp.unlink(missing_ok=True) - print("[graphify watch] No code-graph changes detected; graph.json/GRAPH_REPORT.md left untouched.") else: if not _check_shrink( force, existing_graph_data, candidate_graph_data, @@ -2221,60 +2298,31 @@ def _failed(f: str) -> bool: failed_sources=failed_sources, ): return False - from graphify.exporters.html import _HTML_STALE_MARKER - # Mark before graph.json advances so an interruption cannot leave a - # previous visualization looking current to the fast path. - (out / _HTML_STALE_MARKER).touch() from graphify.export import backup_if_protected as _backup _backup(out) - # os_replace_with_fallback, not a plain Path.replace (#2689): this - # function read existing_graph a few lines up for the same_graph - # comparison, and on a VMware HGFS shared folder a replace over a - # destination read earlier in the same process raises - # PermissionError even on the same drive. - os_replace_with_fallback(graph_tmp, existing_graph) - write_text_atomic(report_path, report) - # Keep the membership signatures in step with the labels we just wrote. - # Skipping this was the other half of the stale-label bug: labels.json - # advanced every rebuild while the sidecar kept describing an older - # clustering, so the guard above had nothing accurate to check against. - # - # Each write is atomic, so a kill mid-write can never publish a - # half-written sidecar (a torn one would be unparseable, which the - # guard reads as "no saved signatures" and falls back to the - # count heuristic). - # - # Labels go down BEFORE the signatures, and that order matters. The - # guard above compares the SAVED signatures against ones recomputed - # from the current clustering — not against the labels. So publishing - # signatures first and crashing would leave a sidecar that already - # describes the new clustering sitting beside the OLD labels: the - # guard recomputes the same signatures, finds them equal, reports - # nothing stale, and silently keeps names that describe a clustering - # that no longer exists. Writing labels first keeps the sidecar - # trailing, which the guard sees as a mismatch and re-labels. - write_text_atomic(labels_file, labels_json) - write_text_atomic( - sig_file, json.dumps({str(k): v for k, v in cur_sigs.items()})) - - # See _graphify_root_marker_value for why this isn't always the raw - # caller-supplied value (#3375). - (out / ".graphify_root").write_text( - _graphify_root_marker_value(watch_path), encoding="utf-8" - ) - + # The graph, report and labeling sidecars share publication ownership. + # Keep stale-marker-before-graph and label-before-signature ordering for + # crash recovery; ordinary exceptions roll back the entire cohort. + from graphify.exporters.html import _HTML_STALE_MARKER + products = (*accepted_products, "GRAPH_REPORT.md", labels_file.name, + sig_file.name, _HTML_STALE_MARKER) try: - from graphify.detect import save_manifest - # Full-scan save: prune excluded-but-alive rows (#1908); - # leave failed AST sources unstamped (#2543). - save_manifest( - _ast_manifest_files(), manifest_path=str(out / "manifest.json"), - kind="ast", root=watch_root, - scan_corpus={f for _fl in detected["files"].values() for f in _fl}, - clear_ast=_failed_ast_sources or None, - ) - except Exception: - pass + with ProductPublication(out, products, replace=os_replace_with_fallback) as publication: + if not no_change: + import shutil + publication.stage(_HTML_STALE_MARKER).touch() + shutil.copyfile(graph_tmp, publication.stage("graph.json")) + graph_tmp.unlink(missing_ok=True) + write_text_atomic(publication.stage("GRAPH_REPORT.md"), report) + write_text_atomic(publication.stage(labels_file.name), labels_json) + write_text_atomic(publication.stage(sig_file.name), + json.dumps({str(k): v for k, v in cur_sigs.items()})) + _stage_analysis_products(publication) + publication.commit() + finally: + graph_tmp.unlink(missing_ok=True) + if no_change: + print("[graphify watch] No code-graph changes detected; graph.json/GRAPH_REPORT.md left untouched.") # Reconcile from the persisted graph. The stale marker was written # before graph.json advanced, so a failed or interrupted atomic render @@ -2342,7 +2390,8 @@ def _notify_only(watch_path: Path) -> None: def _has_non_code(changed_paths: list[Path]) -> bool: - return any(p.suffix.lower() not in _CODE_EXTENSIONS for p in changed_paths) + from graphify.qml_safety import is_qml_path + return any(p.suffix.lower() not in _CODE_EXTENSIONS and not is_qml_path(p) for p in changed_paths) def _batch_triggers_rebuild(batch: list[Path]) -> bool: @@ -2354,7 +2403,8 @@ def _batch_triggers_rebuild(batch: list[Path]) -> bool: this, a doc-only deletion batch would sit behind the needs_update flag until the next code event or a manual `graphify update` (#2580). """ - has_code = any(p.suffix.lower() in _CODE_EXTENSIONS for p in batch) + from graphify.qml_safety import is_qml_path + has_code = any(p.suffix.lower() in _CODE_EXTENSIONS or is_qml_path(p) or p.name in {".graphifyignore", ".gitignore"} for p in batch) has_deletion = any(not p.exists() for p in batch) return has_code or has_deletion @@ -2423,24 +2473,29 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None: class Handler(FileSystemEventHandler): def on_any_event(self, event): - nonlocal last_trigger, pending + nonlocal last_trigger, pending, ignore_patterns if event.is_directory or _is_read_only_event(event): return path = Path(os.fsdecode(event.src_path)) + policy_change = path.name in {".graphifyignore", ".gitignore"} and _is_relative_to(path, watch_root_for_ignore) + if policy_change: + ignore_patterns = _load_graphifyignore(watch_root_for_ignore, + gitignore=_read_build_gitignore(watch_path / _GRAPHIFY_OUT)) # Check .graphifyignore BEFORE the extension/dotfile/out filters so # the cheapest short-circuit for users with broad ignore patterns # (node_modules/, .venv/, build/, …) fires first. _is_ignored # tolerates absolute paths outside watch_root via its internal # relative_to guard, so a stray symlinked event won't raise. - if ignore_patterns and _is_ignored(path, watch_root_for_ignore, ignore_patterns): + if not policy_change and ignore_patterns and _is_ignored(path, watch_root_for_ignore, ignore_patterns): return - if path.suffix.lower() not in _WATCHED_EXTENSIONS: + from graphify.qml_safety import is_qml_path + if not policy_change and path.suffix.lower() not in _WATCHED_EXTENSIONS and not is_qml_path(path): return try: filter_parts = path.relative_to(watch_root_for_ignore).parts except ValueError: filter_parts = path.parts - if any(part.startswith(".") for part in filter_parts): + if any(part.startswith(".") for part in (filter_parts[:-1] if policy_change else filter_parts)): return if _GRAPHIFY_OUT in filter_parts: return diff --git a/graphify/watch_coowners.py b/graphify/watch_coowners.py new file mode 100644 index 0000000000..96e36ab51d --- /dev/null +++ b/graphify/watch_coowners.py @@ -0,0 +1,69 @@ +"""Expand incremental work to already admitted owners of changed physical bytes. + +Detection, exclusions, deletion handling, extraction and publication remain with +the watch caller. This boundary reads only filesystem identity, never source +content, and retains no cache or mutable state after one selection operation. +""" +from __future__ import annotations + +from collections.abc import Iterable +import json +import os +from pathlib import Path +import stat + + +SOURCE_CONTEXT_LIMIT = 160 + + +def _safe_source(path: Path, root: Path) -> str: + """A failed identity lookup cannot justify resolving a diagnostic path again.""" + try: + relative = path.relative_to(root).as_posix() + except ValueError: + return "" + if len(relative) > SOURCE_CONTEXT_LIMIT or any( + ord(char) < 32 or 127 <= ord(char) < 160 or char in "\u2028\u2029" + for char in relative): + return "" + return relative + + +def physical_identity(path: Path, root: Path) -> tuple: + """Verify a regular contained input before comparing shared physical content.""" + try: + resolved = path.resolve(strict=True) + resolved.relative_to(root) + value = path.stat() + if not stat.S_ISREG(value.st_mode): + raise ValueError("not a regular admitted source") + if value.st_dev and value.st_ino: + return "inode", value.st_dev, value.st_ino + # Some filesystems do not expose inode identity. A canonical path still + # proves symlink/junction co-ownership without guessing about hardlinks. + return "path", resolved + except (OSError, RuntimeError, ValueError): + source = json.dumps(_safe_source(path, root), ensure_ascii=True) + raise ValueError(f"WATCH_SOURCE_IDENTITY_FAILED: source={source}; admitted source " + "identity unavailable; prior products retained; repair and retry") from None + + +def expand_changed_coowners(wanted: Iterable[Path], corpus: Iterable[Path], *, root: Path) -> list[Path]: + """Append verified co-owners without authorizing discovery or deleted inputs. + + The caller supplies only positive, admitted, nonsemantic source paths. Keep + notification order and append additional owners in accepted corpus order. + A failed lookup refuses partial work so publication can retain its cohort. + """ + wanted = list(wanted) + if not wanted: + return wanted + accepted = {Path(os.path.abspath(path)): Path(path) for path in corpus} + changed = {Path(os.path.abspath(path)) for path in wanted} + if not changed <= accepted.keys(): + raise ValueError("WATCH_SOURCE_IDENTITY_FAILED: source=\"\"; changed source is not " + "admitted; prior products retained; repair and retry") + identities = {path: physical_identity(path, root) for path in accepted} + affected = {identities[path] for path in changed} + return wanted + [original for path, original in accepted.items() + if path not in changed and identities[path] in affected] diff --git a/pyproject.toml b/pyproject.toml index e910975a8f..f393ab2dc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,8 @@ vbnet = ["tree-sitter-vb-dotnet==0.3.0"] # language pack bundles both grammars in a prebuilt, offline-compatible wheel. # Optional extras (large shared dep, resolved once in `all`). r = ["tree-sitter-language-pack==0.11.0"] +# QML-00 verified the offline qmljs grammar/API; keep the shared pack optional. +qml = ["tree-sitter-language-pack==0.11.0"] erlang = ["tree-sitter-language-pack==0.11.0"] solidity = ["tree-sitter-solidity==1.2.13"] all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.16.1", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "graspologic-native>=1.3.1,<2; python_version >= '3.13'", "python-docx", "openpyxl", "psycopg[binary]", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.7.4", "matplotlib", "pillow>=12.3.0", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba; python_version < '3.12'", "jieba-py>=0.46.12,<1; python_version >= '3.12'", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp", "robotframework>=4.0", "tree-sitter-vb-dotnet==0.3.0", "tree-sitter-language-pack==0.11.0", "tree-sitter-solidity==1.2.13"] diff --git a/tests/TRACEABILITY.md b/tests/TRACEABILITY.md new file mode 100644 index 0000000000..7f76253336 --- /dev/null +++ b/tests/TRACEABILITY.md @@ -0,0 +1,1740 @@ +# Acceptance traceability + +## Upstream submission scope + +The [scoped upstream contribution](../docs/qt-qml/UPSTREAM_CONTRIBUTION.md) retains bounded Qt/QML analysis +and its source, incremental, publication and consumer contracts. REQ-QML-019 +and REQ-QML-021, D12/D16, and the generic viewer portions of INC-QML-09/13/16 +are deferred for this submission. Native Codex REQ-CORE-001-AC01/AC05 and the +broad fork repository policy are also deferred; the baseline installer and +repository policy apply. Constructor and source-containment work in INC-QML-13 +remains in scope. + +Aggregate source-edge counts also remain as a REQ-QML-020-AC01 dependency, +tested by `tests/test_qt_project_membership_updates.py::test_req_qml020_ac01_unused_packaged_component_survives_aggregate_export` +through the existing upstream inspector harness. That partial source-count +projection does not complete deferred REQ-QML-019-AC02. Startup selection is not +part of the retained membership criterion. + +### Scoped local preparation — INC-QML-53 + +The first 135-module Qt/QML source selection passes 2,379 tests with 38 skips +and fails the aggregate membership case because its generic inspector helper +was removed. The skips are three missing wheel-input cases, nineteen unavailable +native symlink-capability cases and sixteen POSIX link/parent semantics cases. +The correction preserves the two external links, degree, positive internal count +and total source-edge accounting. The new parametrized +`tests/test_qt_project_membership_updates.py::test_req_qml020_ac01_aggregate_counts_keep_source_edges_and_reject_invalid_fields` +also checks parallel/directed/self-loop conservation, unchanged input graph and +rejection of boolean, negative and injected string counts through production HTML. +All four aggregate cases fail before the correction. + +Executed corrected command: +`python -X utf8 -m pytest tests/test_qt_project_membership_updates.py tests/test_qt_graph_html_payload.py tests/test_qt_html_consumers.py tests/test_export.py tests/test_cli_export.py -q --tb=short -p no:cacheprovider -rs`: +**145 passed, zero failures/skips**. Focused Ruff and explicit-interpreter Pyright +pass. HTML is 717 lines within its 760-line scope ceiling; membership tests are +299 lines within 300. Whole-project typing remains the recorded baseline gap. + +`python -X utf8 -m pytest tests/test_install.py tests/test_install_references.py tests/test_install_roundtrip.py tests/test_skill_auto_refresh.py tests/test_uninstall_scope.py tests/test_settings_merge.py -q --tb=short -p no:cacheprovider -rs`: +**265 passed, five skipped**. Four require unavailable native symlink capability; +one progressive fixture profile has no applicable shipped assistant. + +`uv build --wheel --offline` with a fresh setuptools build base produces the +scoped wheel; all 182 packaged Python files match source bytes and deferred +hook/community/navigation modules are absent. The wheel SHA-256 is +`c9b413aac68289a2055baf1d70f7efda3fb1b8a4554ad7bb81b0ef6c94e90439`. +With that wheel as `GRAPHIFY_QML_TEST_WHEEL`, +`python -X utf8 -m pytest tests/test_qml_platform_matrix.py tests/test_qml_wheel_artifact.py tests/test_wheel_packaging.py -q --tb=short -p no:cacheprovider -rs`: +**138 passed, zero failures/skips**, separately clearing the three artifact-input +exclusions. Other source skips remain gaps on this local profile. AST-only graph +refresh completes with 23,096 nodes/53,444 edges and no provider calls. These +local results do not replace final-head normal hosted validation. + +Two fresh offline environments install the attested wheel with `[qml,watch]` +and with core dependencies only. Isolated `python -I tests/qml_installed_smoke.py` +and its `--core-only` profile pass from a neutral working directory on Windows +Python 3.12.14/tree-sitter 0.25.2. Actual parser absence, safe unrelated extraction, +native emission and optional native/module joins execute through installed +production entry points. The smoke's audit guard rejects network/process use +during analysis. Full `python -m pyright --pythonpath ` +still fails with 605 errors/zero warnings; focused touched-code typing passes. +Comparing file/severity/rule/message multiplicities with the recorded integration +checkpoint finds zero added or removed diagnostic signatures. Source-line shifts +are excluded from that comparison; unchanged errors remain failures. + +The complete native command shown above finishes **9,202 passed, 45 failed, +122 skipped**. JUnit identifies all 45 failures as inaccessible WindowsApps +Bash alias / missing `sh` setup in `tests/test_hooks.py` (13), +`tests/test_shell_portability_helpers.py` (6) and +`tests/test_skillgen_input_path_injection.py` (26). No Qt/QML assertion fails: +the 137 Qt/QML modules report **2,392 passed, zero failed, 38 skipped**. +The full run also excludes sixteen shell-dependent cases in hooks and +`tests/test_hook_chain_survives_skip.py`. The initial incomplete-bundle retry +reports 157 passed, seven failed and eight skipped; failures require adjacent +DLLs or the absent path mapper. The admission owner configures complete portable +Git for Windows 2.56.0 in test-process PATH and reruns four unchanged modules. +The [official release](https://github.com/git-for-windows/git/releases/tag/v2.56.0.windows.1) +archive has verified SHA-256 +`eceb5e061aa90df2f69ddd3e90f0030e1b8037a7829934bc40e4be1caa1accc1`. +The initial full failure remains evidence; a focused corrected result does not +claim a repeated whole-suite pass. Native symlink/POSIX, compiler, service and +other documented profile exclusions remain separate. + +Corrected command: +`python -X utf8 -m pytest tests/test_hooks.py tests/test_shell_portability_helpers.py tests/test_skillgen_input_path_injection.py tests/test_hook_chain_survives_skip.py -q --tb=short -p no:cacheprovider -rs`: +**171 passed, zero failed, eight skipped** under the CI-compatible isolated PATH, +exact Python shim and offline installer guards. Python 3.12.14, Bash/sh 5.3.15, +cygpath 3.6.10, Git 2.56.0.windows.1 and Node 24.19.0 execute actual production +consumers. The eight exclusions are two unavailable symlink cases, one POSIX +executable-shim case and five explicit native-Windows hook exclusions. Failed +whole-suite and incomplete-bundle receipts remain separate from this result; +no repeated whole-suite pass is claimed. The original local coverage file is +unchanged and tooling stays outside published sources. + +The [complete frozen fork](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4) +and its recorded 8b6c9d2 source/runner evidence retain their own revision scope. +Deferred feature sections and their exact tests below are historical evidence, +not implemented or verified contracts of this narrower contribution. A changed +contribution head requires fresh applicable checks; prior totals, skips and +hosted results do not establish those checks. + +Current criterion IDs use `REQ-QML-`; completion increments use `INC-QML-`. +[Legacy aliases](../docs/qt-qml/IDENTIFIERS.md) retain earlier evidence identity. +Recorded test identities retain their historical evidence; deferred viewer and +native-hook tests are mapped to their frozen owners below. Source annotations +keep their IDs. + +The current catalog contains twenty-one requirements and eighty-five stable +acceptance criteria. REQ-QML-018 has seven criteria, including the separate AC07 +header-dispatch contract; REQ-QML-019/020/021 retain four/three/three criteria. +INC-QML-00–07 hosted proof describes its original seventeen-requirement, +sixty-eight-criterion profile. Subsequent evidence does not retroactively broaden +those historical results. + +INC-QML-11/15/08a/b/c and INC-QML-28–38 have recorded local source, update, consumer +and reviewed installed-wheel evidence. All seven REQ-QML-018 criteria are locally +verified for the bounded Windows x64/Python 3.12 profile. Individual assignments +and the [final local matrix](#final-adoption-delivery) distinguish passing cases +from wider platform, native interaction, live-service and runtime limitations. +The recorded full source run has 59 baseline failures and 178 skips, with no added +failure identities; full typing remains a failed baseline gate. The later +[Windows dependency follow-up](../docs/qt-qml/VALIDATION.md#windows-shell-and-optional-dependency-follow-up--2026-10-04) +clears 40 of those 59 in a selective rerun, retains 19, and exposes two previously +skipped hook portability assertions. It does not replace full-suite evidence. +Neither aggregate test totals nor skipped cases establish acceptance. + +The separate [shared compatibility verification](#shared-compatibility-verification) +records `REQ-CORE-` behavior and the all-extras environment. The recorded native +Windows full run at `463ca32` has 8,818 passes, zero failures and 151 skips. Separate unchanged- +source profiles pass all 76 Node and seven preprocessor/wheel cases previously +skipped for setup. Later real Linux/service and native checks pass at the +[verified integrated checkpoint](#verified-integrated-hosted-checkpoint--8b6c9d2). The +59-failure run above is historical; its failure identities are cleared in the +recorded applicable Windows profile. Full typing still fails on existing errors. + +The chronological records below preserve checkpoint evidence. Their older pending +statements, epochs and test totals describe the recorded revisions. Complete-fork +status and proof are in the [verified integrated checkpoint](#verified-integrated-hosted-checkpoint--8b6c9d2), +with earlier adoption evidence retained in +[validation](../docs/qt-qml/VALIDATION.md#final-adoption-delivery). +See [implementation limits](../docs/qt-qml/IMPLEMENTATION.md#final-adoption-delivery), +[export contracts](../docs/qt-qml/EXPORT_MATRIX.md) and +[platform matrix](../docs/qt-qml/PLATFORM_MATRIX.md). + +## Deferred criterion assignments for upstream submission + +These assignments preserve the catalog and recorded evidence without claiming +that excluded features pass on the narrowed contribution. REQ-QML-001–018/020 +and retained shared compatibility criteria keep their behavioral acceptance +unchanged and require evidence applicable to the submitted revision. The retained +Qt aggregate-omission regression does not verify the deferred generic viewer. + +| Criterion | Current contribution assignment | Evidence / follow-up owner | +| --- | --- | --- | +| REQ-QML-019-AC01 | Deferred for this upstream submission; not a current acceptance gate | Separate generic-viewer contribution; frozen complete-fork evidence below | +| REQ-QML-019-AC02 | Deferred for this upstream submission; not a current acceptance gate | Separate generic-viewer contribution; frozen complete-fork evidence below | +| REQ-QML-019-AC03 | Deferred for this upstream submission; not a current acceptance gate | Separate generic-viewer contribution; frozen complete-fork evidence below | +| REQ-QML-019-AC04 | Deferred for this upstream submission; not a current acceptance gate | Separate generic-viewer contribution; frozen complete-fork evidence below | +| REQ-QML-021-AC01 | Deferred for this upstream submission; not a current acceptance gate | Separate native-navigation contribution; frozen complete-fork evidence below | +| REQ-QML-021-AC02 | Deferred for this upstream submission; not a current acceptance gate | Separate native-navigation contribution; frozen complete-fork evidence below | +| REQ-QML-021-AC03 | Deferred for this upstream submission; not a current acceptance gate | Separate native-navigation contribution; frozen complete-fork evidence below | +| REQ-CORE-001-AC01 | Deferred for this upstream submission; not a current acceptance gate | Separate native Codex installer contribution; frozen complete-fork evidence below | +| REQ-CORE-001-AC05 | Deferred for this upstream submission; not a current acceptance gate | Separate native Codex installer contribution; frozen complete-fork evidence below | + +## Shared compatibility verification + +The Windows x64/Python 3.12 checkpoint installs all extras from the unchanged +lock with `uv sync --all-extras --frozen`; `uv pip check` reports 197 compatible +packages. Tree-sitter remains 0.25.2, HCL 1.2.0 and language-pack 0.11.0. +The [compatibility plan](../docs/COMPATIBILITY.md) records profile setup, exact +remaining system procedure, ownership, size ceilings and consumer limitations. + +| Acceptance ID | Exact automated assignment | Checkpoint evidence and gaps | +| --- | --- | --- | +| REQ-CORE-001-AC01 | [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac01_codex_hook_dispatches_spaced_native_launcher`); `tests/test_install.py::test_codex_hook_command_is_a_real_cli_subcommand` | Historical complete-fork evidence; deferred for this submission. Exact Cmd and Windows PowerShell 5.1 argument transports pass with a real selected launcher, failing PATH decoy and unsafe control; full-source and independent installed-artifact execution pass in the current native evidence below | +| REQ-CORE-001-AC02 | `tests/test_install.py::test_hermes_skill_destination_posix_uses_home`; `tests/test_install_references.py::test_gemini_install_references_all_resolve`; `tests/test_install_roundtrip.py::test_skill_roundtrip_at_real_destination` | Exact destination and Linux/Windows contract profiles pass locally; simulated platform selection does not prove another kernel | +| REQ-CORE-001-AC03 | `tests/test_skill_auto_refresh.py::test_every_stale_platform_is_refreshed_not_only_the_detected_one`; `tests/test_skill_auto_refresh.py::test_a_stale_gemini_skill_gets_the_warning_too` | Windows-adapted bytes and retained ambiguous shared copy pass; shared ownership isolation remains unresolved upstream | +| REQ-CORE-001-AC04 | `tests/test_uninstall_scope.py::test_bare_call_still_removes_global`; `tests/test_uninstall_scope.py::test_remove_user_skill_opt_in_with_project_dir` | Linux/Windows scope profiles and protected shared-dir retention pass; no new uninstall policy | +| REQ-CORE-001-AC05 | [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_literal_expansion_characters_retain_launcher_identity`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_missing_launcher_fails_without_path_fallback`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_selected_process_failure_preserves_nonzero_exit`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_reinstall_and_uninstall_preserve_unrelated_hooks`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_unsupported_shell_rejects_before_settings_access`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_shell_inspection_failure_retains_settings_and_backup`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_command_length_boundary_retains_literal_identity`) | Historical complete-fork evidence; deferred for this submission. Native Cmd/Windows PowerShell 5.1 executable identity, failure/retention and rejected serialization boundaries pass in full-source and independent installed-artifact execution. Synthetic length cases establish admission/retention, not a real long-path launch. Actual Codex event delivery and other PowerShell versions remain separate system gaps. | +| REQ-CORE-002-AC01 | `tests/test_filesystem_profiles.py::test_req_core_002_ac01_detect_rejects_nonregular_stat_modes`; `tests/test_filesystem_profiles.py::test_req_core_002_ac01_stat_failures_are_unreadable`; original real fixtures in `tests/test_non_regular_files.py` | Native admission/error seams and hosted real FIFO/socket cases pass at the verified 10cb15a checkpoint below; privileged device/link fixtures retain explicit capability exclusions | +| REQ-CORE-002-AC02 | `tests/test_filesystem_profiles.py::test_req_core_002_ac02_unavailable_cwd_rejects_before_artifacts`; `tests/test_filesystem_profiles.py::test_req_core_002_ac02_repo_root_recovers_cwd_and_publishes_graph`; `tests/test_watch.py::test_rebuild_code_deleted_cwd_without_repo_root_returns_false`; `tests/test_watch.py::test_rebuild_code_deleted_cwd_uses_graphify_repo_root` | Native simulated lookup-failure and real chdir/persistence pass; actual removed-CWD POSIX cases pass individually in all four 10cb15a Linux lanes below | +| REQ-CORE-002-AC03 | `tests/test_filesystem_profiles.py::test_req_core_002_ac03_unavailable_cwd_reports_root_reason` | Missing-root/chdir-denied assertions failed before correction; all three safe diagnostic categories pass after correction | +| REQ-CORE-003-AC01 | `tests/test_terraform_modules.py::test_same_named_directories_and_cross_file_references_stay_separate` | Both LF/CRLF cases pass exact four identities/references and exclude cross-directory targets | +| REQ-CORE-003-AC02 | `tests/test_terraform_modules.py::test_same_named_directories_and_cross_file_references_stay_separate`; `tests/test_terraform_modules.py::test_raw_extractor_scopes_ids_by_full_directory` | Production extraction, directed build and JSON reload pass portable paths, locations and scope; Terraform production unchanged | + +Focused commands and results at the checkpoint: + +- Final INC-CORE-05 consumer correction: [frozen historical invocation](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/TRACEABILITY.md#L68) passes 19 cases, with no skips and one existing Hypothesis discovery warning. Both actual Windows shells exercise the persisted command. Installer/settings-merge/hook characterization passes 143 cases with three existing profile skips; these selections overlap and are not added to the full-suite count. +- [frozen historical invocation](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/TRACEABILITY.md#L69): 243 passed, five native symlink skips. Strengthened exact-launcher/subcommand follow-up: two passed. +- `pytest tests/test_hooks.py tests/test_skillgen_input_path_injection.py -q`: 143 passed, eight pre-existing profile skips. The final affected selection plus shell-identity helpers: 37 passed, no skips. All 20 hostile-path cases include reachable unsafe controls. +- `pytest tests/test_watch.py tests/test_non_regular_files.py tests/test_filesystem_profiles.py -q`: 198 passed, eleven profile/capability skips, one existing Hypothesis warning. Seven formerly failing impossible Windows fixtures now remain explicit system gaps; sixteen new portable cases pass. +- `pytest tests/test_terraform.py tests/test_terraform_modules.py tests/test_extractors_registry.py::test_terraform_migrated -q`: 42 passed, one unavailable symlink skip, one existing Hypothesis warning. +- `ruff check .`: passed. `python -m tools.skillgen --check`: all 134 artifacts match. Lockfile unchanged. Full native source and refreshed installed-wheel evidence supersedes the restart gap in the current record below; actual Linux execution remains pending. + +These selections overlap and are not added into a full-suite total. Real filesystem +capability assertions, native hook consumer execution and live-service tests remain +separate from contract profiles and fake-provider boundaries. Full typing is a +separate gate and is not established by Ruff or selected tests. + +### Current native source and artifact evidence — 2026-10-05 + +Frozen source: `463ca3226c4aec4390a1e931fb1be1582059d1ca`, Windows x64, +Python 3.12.14, all 197 locked source-environment distributions. The full source +command is the isolated driver's `pytest tests/ -q --tb=short -p no:cacheprovider +-rs --junitxml=/full.xml`, using the explicit environment Python with +`-I -X utf8` and canonical source imports. The profile exposes portable Git +Bash/sh and a literal Python forwarding shim, isolates provider/home/install +state and disables installation fallback. Pytest returns zero: **8,818 passed, +zero failures/errors, 151 skipped, 20 warnings**, with 1,143.29 seconds wall time. + +The original restricted PATH excluded the already installed Node and GNU C +preprocessor, and the reviewed-wheel variable was absent. Supplemental profiles +retain that same source/dependency revision and execute exact skipped IDs: + +- Node 24.19.0: **76 passed**, zero failures/skips, 26.42 seconds. +- GNU `cpp` 13.1.0 and reviewed wheel input: **four Fortran preprocessor and three wheel cases passed**, zero failures/skips, 4.68 seconds. This compiler is a test oracle; Graphify analysis gains no compiler or Qt SDK runtime dependency. + +The full-run counts are retained without adding these separate profiles. The +remaining 68 distinct initial skips are 63 Windows/POSIX capability or fixture +exclusions, two other-Python-version cases, one inapplicable obsolete bundle +fallback and two unavailable FalkorDB integration cases. Skips remain exclusions, +not passes. The hosted increment assigns applicable Linux/service cases explicitly. +The 20 warnings are nine clustering dependency warnings, seven Solidity parser +deprecations, two expected cache-rejection diagnostics, one Hypothesis collection +warning and one Starlette/httpx deprecation; no warning caused a failed assertion. + +All three profiles retain HEAD, 667 Python-file fingerprints, source dependencies +and original coverage bytes. The canonical source-fingerprint map SHA-256 is +`36155c56557d2a52bb8384fa8964581de57cc22c9b5ec46703d0fb8f0916e291`. +Full Pyright 1.1.409 with the explicit source interpreter reports **604 errors, +zero warnings, 663 files**, exit one. Compared with immutable source `135d50b` +under the same dependencies/configuration, all 604 diagnostic identities match: +zero added and zero removed. The typing gate remains failed. + +Reviewed wheel SHA-256: +`057c509296b9f6e25be1dd39b72f3d463f10cdccfb7a0b95836f510f016dd518`. +All 180 packaged Python payloads match current source bytes. Relative to the +earlier reviewed 179-module wheel, 102 differences are only LF/CRLF spelling; +newline-normalized source and parsed syntax differ only in `install.py`, +`watch.py` and the new [frozen codex_hook_command owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/codex_hook_command.py). Independent installed consumption +pins all Graphify imports to its 163-distribution runtime from a neutral directory: +**35 hook/filesystem cases pass**, with zero failures/skips. `uv pip check` +confirms compatibility. This is complementary installed-consumer evidence, +not a replacement for the earlier Qt/QML artifact matrix or a hosted result. + +### Hosted acceptance assignments + +The CI implementation and exact hosted execution remain separately reviewable. +Each job's JUnit artifact and GitHub conclusion must be read for the same tested +source/configuration revision; configuration validation alone cannot pass these +criteria. [Hosted procedure](../docs/COMPATIBILITY.md#hosted-runner-proof-procedure) +owns setup, revision identity, exclusions and recovery. + +| Acceptance ID | Exact automated job/system assignment | Current evidence | +| --- | --- | --- | +| REQ-CORE-004-AC01 | `.github/workflows/ci.yml::test` four Python lanes; actual fixtures in `tests/test_non_regular_files.py`, `tests/test_cpp_preprocess.py`, `tests/test_qml_wheel_artifact.py`, `tests/test_falkordb_integration.py` | Hosted `10cb15a`: all four full jobs pass; required Linux/service/wheel regressions and exact corrected cleanup case individually pass. Explicit platform/baseline skips retain their assignments | +| REQ-CORE-004-AC02 | `.github/workflows/ci.yml::windows-compatibility`; exact CORE functions above and assigned HTML/installer/shell modules | Hosted `10cb15a`: 836 passes, 21 explicit skips, zero failures. All original shell failures and revised admission cases individually pass with retained consumer identity proof | +| REQ-CORE-004-AC03 | Source identity/readiness, pytest, retention, service cleanup and artifact steps in both CI source profiles; GitHub run/job/artifact read for actual checkout | Hosted `10cb15a`: all five API-digest-verified source archives retain identity/JUnit/integrity; native report includes exact two-shell consumer identities. Four real services/test graphs are confirmed removed | +| REQ-CORE-004-AC04 | CI trigger/permission/concurrency configuration review and hosted procedure's PR/recovery event audit | Normal PR events own both workflows; no duplicate dispatch. All four `10cb15a` source lanes finish successfully; earlier matrix cancellations remain historical incomplete evidence | + +Local configuration review parses both workflows with PyYAML `BaseLoader`, +compiles each embedded Python block, checks extracted Ubuntu scripts with +`bash -n`, and parses the Windows script through PowerShell's +`System.Management.Automation.Language.Parser.ParseFile`. The CI workflow is +449 physical lines within its documented 450-line cohesion ceiling. Existing +triggers, Ubuntu matrix, skill-generation/security jobs and QML-wheel matrix/job +selection remain intact. Both workflow concurrency groups now use distinct run +IDs outside PR events. These are local configuration results, not hosted passes. + +At first publication `3b005d3`, the delivery changes only CI and documentation +after the completed native run: all 667 Python-file fingerprints and 197 dependency versions match the +tested source, and original coverage bytes are preserved. `ruff check .` passes, +`python -m tools.skillgen --check` matches all 134 artifacts, and the document +check resolves every acceptance criterion and exact test reference. The graph +refresh is AST-only navigation maintenance, not additional acceptance evidence. + +The first hosted source head `3b005d3` produced +[CI admission failure 37244260265](https://github.com/SlinkyRamey/graphify/actions/runs/37244260265) +before creating any jobs: job-level `env` cannot reference `runner.temp`. +Resolving the evidence directory inside each Ubuntu script from `RUNNER_TEMP` +preserves the same artifact location and also admits the independent cleanup +step after failed setup. Official checksum-verified actionlint 1.7.12 rejects +the original configuration with that context error and accepts both corrected +workflows with zero diagnostics, without suppressions. This semantic check +complements YAML/Python/native shell syntax; ShellCheck and Pyflakes were not +installed for the actionlint run. No Linux/service pass is inferred from the +admission failure or local correction. + +At the same source head, +[QML wheel run 37244275402](https://github.com/SlinkyRamey/graphify/actions/runs/37244275402) +passes all four Ubuntu artifact lanes but fails the installed native-module +smoke assertion in all four Windows and all four macOS lanes. These are current +failed gates under investigation, not skips or passing platform acceptance. + +### Canonical accepted-input identity — INC-QML-39 + +The first hosted smoke failure is reproduced locally with a real Windows +short-path alias. Canonical spelling passes; the unchanged installed smoke +fails through the shortened spelling before the correction. The membership +boundary now compares canonical contained input identity against the accepted +declaration. Stored lexical provenance, literal lookup, Qt policy 18 and AST +schema 12 retain their contracts. This local reproduction explains the Windows +failure; matching macOS temporary-directory aliases remain a hypothesis until +the corrected hosted lane passes. + +| Acceptance ID | Exact automatically collected assignment | Evidence and limits | +| --- | --- | --- | +| REQ-QML-020-AC01 | `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac01_real_parent_alias_preserves_smoke_and_cold_warm_identity` | Unchanged native smoke, canonical/alias/reanchored identity, immutable source bytes and warm unrelated Python cache; real Windows short path passes, portable parent symlink requires host capability | +| REQ-QML-020-AC02 | `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac02_path_alias_cannot_authorize_foreign_source`; `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac02_input_resolution_failure_is_bounded_and_preserves_facts`; `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac02_conflicting_transport_retains_durable_products_and_recovers` | Outside-root/conflicting input and OSError/RuntimeError reject boundedly without changing facts; real manual/watch publication preserves graph, manifest, stamp and root marker, then recovers and repeats | +| REQ-QML-020-AC03 | `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac03_alias_updates_remove_stale_membership_and_match_full_rebuild` | Real manual/watch build-declaration removal retires its stale site/edges, matches canonical full rebuild and repeats idempotently | + +Before the production correction, the alias selection records five failures, +two passing rejection controls and seven unavailable-symlink skips. After the +correction, alias plus existing project-membership/lifecycle selections pass +48 cases with seven capability skips and one existing Hypothesis warning. +The seven skips are real directory-symlink capability exclusions on this Windows +host; they do not establish portable alias acceptance. The production owner is +198 physical lines; the new test module is 222, within the 300-line ceiling. +The frozen broader source selection passes 2,259 cases with nine capability +skips, two warnings and zero failures/errors in 527.19 seconds wall time. Its +command uses the isolated source interpreter with all `tests/test_qml_*.py` and +`tests/test_qt_*.py` files except the separately assigned wheel-artifact module, +then `-q --tb=short -p no:cacheprovider -rs --junitxml=REPORT`. All 668 Python-file +fingerprints, 197 dependencies and original coverage bytes remain unchanged; +the source-map digest is +`c2ad0bc662ab2780385766dd22fc56d0bf6a4f5cb059e4e960d61979b60878d4`. +The nine skips are the seven new and two pre-existing real-symlink exclusions. +The two warnings are existing Hypothesis collection and Starlette/httpx notices. +This selection overlaps earlier runs and is not added to their totals. + +Fresh wheel SHA-256 +`f6a3c7befa8cae62ef70a6d89649b64cdc409fb25056d2ee383166c7fe8be522` +contains 180 Python payloads identical to source, changing only the membership +owner relative to the preceding core-05 wheel. Ordinary QML and core-only smoke +pass in clean constrained installed environments; every loaded production module +comes from the independent installation. A near-root real Windows temporary +short alias fails with `QT_CPP_LIMIT`: shared facade normalization publishes +traversal source provenance for contained C++ files. The same case fails in fresh +source with and without the offline guard. INC-QML-40 owns this separate defect; +the native traversal rejection is correct and is retained. Complete installed +alias acceptance and corrected hosted proof remain outstanding. +Existing REQ-QML-004-AC04 and +REQ-QML-012-AC01/AC02 containment/failure assignments remain applicable; no +new corpus reader, persistence owner or diagnostic code is introduced. + +On the frozen 198/222-line production/test payload, Ruff 0.15.14 passes and +targeted Pyright 1.1.409 reports zero errors/warnings. A fresh whole-project +comparison uses the same Python 3.12.14, 197 dependencies and typing configuration +for current source and immutable `135d50b`: both report 604 errors, zero warnings, +with exactly 604 shared diagnostic identities and zero added/removed. Current +source analyzes 664 files in 24.022 seconds; baseline analyzes 658 in 22.615. +Both whole-project commands exit one. The expanded alias coverage adds no typing +diagnostic; it does not pass the existing whole-project typing gate. + +The failure-boundary review separately reproduces persistent OSError and +RuntimeError at the accepted CMake input after source/index creation, through +direct joins and manual/watch writers. Diagnostic construction resolves the +failed input again and rethrows its raw body, leaving later sources unannotated. +Manual exits one and watch returns false; graph/manifest/stamp/root-marker bytes, +repair and no-change repeat pass. The six opt-in probes fail the bounded-body +acceptance assertion. INC-QML-41 owns ordinary regression promotion and correction +for REQ-QML-012-AC01/AC02 and REQ-QML-020-AC02. This is a diagnostic gap; no data +loss is inferred. + +### Canonical facade provenance — INC-QML-40 + +The owning normalization now decides containment from cached physical identity +before external fallback, preserving written/resolved endpoint key forms and +strict native traversal rejection. A nearby real alias is tested independently +of the older external helper's deep-path basename fallback. True external input +and a foreign implementation cannot become in-root membership/native authority. + +| Acceptance ID | Exact automatically collected assignment | Local evidence and limits | +| --- | --- | --- | +| REQ-QML-020-AC01 | `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac01_nearby_alias_preserves_generic_cold_warm_provenance`; `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac01_nearby_alias_preserves_unchanged_native_smoke_and_mixed_graph`; `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac01_nearby_alias_preserves_real_decl_def_provenance` | Actual generic/Python/C++/QML source identity, CRLF bytes, canonical/alias cold/warm graphs and real declaration/definition carriers pass | +| REQ-QML-020-AC02; REQ-QML-004-AC04 | `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac02_foreign_implementation_cannot_supply_an_in_root_definition`; `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac02_physical_external_source_keeps_external_policy_without_membership` | Explicit foreign input keeps external source policy/native rejection; no target membership or implicit discovery is added | +| REQ-QML-020-AC03 | `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac03_nearby_alias_updates_retire_membership_and_generic_targets`; `tests/test_qt_alias_policy_upgrade.py::test_req_qml020_ac03_policy18_no_change_upgrade_retains_repairs_and_repeats` | Real manual/watch generic then metadata-only edits, stale target/site retirement, canonical full parity, unrelated Python identity and mixed/C++-only no-edit policy upgrades pass; failed candidate stamp publication retains the cohort and repair/repeat completes | + +Original facade selection: four failed, one passed, five symlink skips. +Mixed and C++-only policy-upgrade selections each fail two cases under policy 18. +Final existing/new alias, policy and six unrelated source-portability/remap +modules pass **65 cases, zero failures/errors, fourteen symlink-capability skips**, +one existing Hypothesis warning, in 13.43 seconds. These selections overlap earlier +proof and are not added to full-suite totals. Policy 19 refreshes prior native/Qt +products at the same package/source version; AST schema 12 and persistence owners +are unchanged. Generic-only prior API products require an explicit forced rebuild. +The final 238/85-line test modules remain below 300; the facade's 8,997 lines stay +within its existing 9,010 ceiling. Current hosted alias/platform proof remains open. + +### Failure-safe join diagnostics — INC-QML-41 + +Diagnostic context no longer rethrows a persistent input identity failure. +Guarded physical containment and lexical fallback are diagnostic-only; foreign, +unavailable, overlong, control and Unicode line/paragraph-separator labels use +an empty `source_file` string. Safe complete relative labels have a 160-character +ceiling and are never truncated into another apparent identity. Every affected +Qt/QML source retains the existing failure record; unrelated Python is untouched. + +| Acceptance ID | Exact automatically collected assignment | Local evidence and limits | +| --- | --- | --- | +| REQ-QML-012-AC01 | `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac01_all_sources_keep_safe_context_after_join_failure`; `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac01_real_alias_uses_canonical_or_explicit_unavailable_context`; `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac01_injected_context_delimiters_are_explicitly_unavailable`; `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac01_context_ceiling_preserves_or_rejects_complete_relative_label` | Ordinary, OSError and RuntimeError joins annotate every source with exact bounded QML_RESOLUTION_FAILED fields; canonical/real alias and 160/161/control boundaries pass | +| REQ-QML-012-AC02; REQ-QML-020-AC02 | `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac02_persistent_failure_retains_products_and_repairs_without_leaking`; `tests/test_qt_pipeline_failure_context.py::test_req_qml020_ac02_foreign_transport_cannot_gain_authority_from_diagnostic_context` | Actual manual/watch QML_GRAPH_PRESERVED outcomes omit backend bodies, retain all four saved products, repair and repeat byte-for-byte; foreign transport/context cannot add authority | + +Focused RED: **nineteen failed, three passed, three symlink-capability skips**, +4.90 seconds. Corrected focused profile: **22 passed, zero failures/errors, three +capability skips**, 6.11 seconds. Existing failure/membership/update compatibility +uses the guarded canonical Python 3.12.14 with all 197 locked distributions: +**136 passed, zero failures/errors, ten symlink skips**, one existing Hypothesis +warning, 115.19 seconds. These profiles overlap earlier selections and are not +combined into a full-suite total. The changed helper covers seven of seven +measured statements and two of two measured branches. Overall pipeline coverage +is 89%; remaining uncovered collector/index-diagnostic paths are unchanged. +The original coverage file is preserved; coverage evidence uses isolated storage. +The production/test owners measure 106/190 lines, within the 300-line ceiling. + +### Final local alias artifact and contribution gates + +The combined frozen INC-QML-39/40/41 candidate is built from 671 source/test/tool +Python files with canonical source-map SHA-256 +`795659a2a35b41b71443ad89e07ee739bef0242d67a0d82b0b41fb79f9ac822b`. +Reviewed wheel SHA-256: +`f2a759cc6ae5dd11d61a73806baebb867ebe4a1929cf06328b1d0473924a6799`. +All 180 packaged Python payloads match source bytes; only membership, facade, +derived policy and pipeline differ from the preceding core-05 wheel. Clean +constrained extra/core environments use 40/30 compatible distributions from the +unchanged 197-version source snapshot. Every loaded production module originates +in that independent installation (128 extra/122 core modules). + +Unchanged offline `-I` QML/core smoke and the real Windows near-root temporary +short-alias native smoke pass from a neutral working directory. Three ordinary +reviewed-wheel metadata/import/parser-boundary tests also pass, with one existing +Hypothesis warning. Artifact proof takes 9.32 seconds; Python is 3.12.14, +tree-sitter 0.25.2, C++ grammar 0.23.4, language-pack 0.11.0 and watchdog 6.0.0. +Source/dependency and original coverage bytes are preserved. This current artifact +replaces the failed first alias candidate for its profile; hosted proof remains open. + +The same fresh optional artifact executes the three ordinary alias, policy-upgrade +and failure-context modules through `python -I -X utf8`, importlib-mode pytest and +an offline process/network guard from a neutral directory: **33 passed, zero +failures/errors/warnings, ten actual symlink-capability skips**, 10.71 seconds. +All 152 loaded Graphify modules originate in the artifact; only the test namespace +is supplied by the source checkout. Its 180 payloads and 40 dependency versions, +the source's 197 dependencies and original coverage bytes remain unchanged. +The first harness attempt placed fixtures under the checkout's ignored `.venv` +and records eight initial-build setup failures. Moving only its owned fixture +directory to the host temporary area corrects setup without changing production, +tests or assertions; both attempts are retained. Capability skips do not establish +Linux/macOS acceptance and these overlapping selections are not added together. + +Ruff 0.15.14 passes the whole repository and all eight touched/new Python files; +all 134 generated skill artifacts match. Actionlint 1.7.12 accepts both corrected +workflows without suppressions. Document checks resolve every acceptance ID and +exact test reference. AST-only graph maintenance is navigation, not acceptance. +Targeted Pyright 1.1.409 on all eight files has 120 existing facade errors, exactly +shared with immutable `135d50b`; the other seven files have zero diagnostics. +Whole-project Pyright has **604 errors, zero warnings, 667 files**, exit one, +20.990 seconds: 604 shared and zero added/removed under identical Python, +dependencies and configuration. Typing remains a failed baseline gate. + +### Hosted Windows application selection — INC-CORE-07 + +At source `f95366d`, the native compatibility job fails before pytest because +Get-Command returns two Node applications and `.Source` projects both paths. +The job retains dependency/import evidence only; identity, pytest exit, JUnit +and post-test integrity are absent. No native test acceptance is inferred from +that setup failure. Node and uv selection now retains one first-PATH application. + +| Acceptance ID | Exact ordinary regression | Evidence and limits | +| --- | --- | --- | +| REQ-CORE-004-AC02 | `tests/test_ci_windows_tools.py::test_req_core004_ac02_workflow_executes_first_real_path_application` | Actual workflow expressions execute a copied preferred real Node/uv binary before the original; scalar path and Node process.execPath prove exact executable identity | +| REQ-CORE-004-AC03 | `tests/test_ci_windows_tools.py::test_req_core004_ac03_missing_workflow_application_has_no_fallback`; `tests/test_ci_windows_tools.py::test_req_core004_ac03_node_guard_rejects_real_incompatible_application` | Missing Node/uv and real uv named as Node reject through owning discovery/version expressions; no fake executable response or fallback | + +Immutable historical workflow: **two failures, three passes**, 7.22 seconds. +Current workflow: **five passes, zero skips/failures/errors**, 13.38 seconds, +native Windows PowerShell 5.1, Node 24.19.0 and actual uv. The focused test is +109 physical lines; Ruff and whitespace checks pass. Original coverage bytes +are retained. Corrected hosted execution and its complete evidence remain pending. + +### Native shell launch identity — INC-CORE-08 + +Hosted `38bf8cc` records 783 native passes, 33 failures and 20 skips. Its retained +artifact binds source/base/checkout, tools and unchanged 197 dependencies/source. +Ten hook and 23 skill input-security cases launch the WSL stub through a bare +Bash process name. A real direct probe confirms that PATH lookup selects Git +Bash while native bare launch invokes another executable. Existing absolute +preflight therefore cannot establish the ordinary Python consumer's identity. + +| Acceptance ID | Exact ordinary regression | Observable outcome | +| --- | --- | --- | +| REQ-CORE-004-AC02 | `tests/test_shell_portability_helpers.py::test_req_core004_ac02_admitted_shell_runs_real_posix_command` | Both selected absolute shells execute real POSIX syntax; no GNU-only assumption is imposed on POSIX | +| REQ-CORE-004-AC02 | `tests/test_shell_portability_helpers.py::test_req_core004_ac02_native_cwd_shadow_requires_explicit_path_admission` | A real native CWD decoy shadows bare launch; configured-PATH selection excludes it until CWD is explicitly admitted | +| REQ-CORE-004-AC02 | `tests/test_shell_portability_helpers.py::test_req_core004_ac02_real_consumers_resist_native_cwd_shadow` | Actual hook Bash/sh and skillgen launchers preserve the admitted process identity and original result assertions | +| REQ-CORE-004-AC03 | `tests/test_shell_portability_helpers.py::test_req_core004_ac03_missing_shell_selection_is_rejected`; `tests/test_shell_portability_helpers.py::test_req_core004_ac03_invalid_shell_identity_is_rejected`; `tests/test_shell_portability_helpers.py::test_req_core004_ac03_unadmitted_shell_name_is_rejected` | Missing/invalid identity or unadmitted names reject before launch without global/system fallback | + +The unchanged original consumer functions fail three real decoy regressions; +fifteen helper cases pass. Corrected ordinary shell/hook/skillgen selection +passes **161 cases, eight explicit Windows/POSIX profile skips**, with all +eighteen helper cases passing. Raw literal payload, hostile-input rejection, +legitimate path, nonexistent path and vulnerable positive controls are retained. +No subprocess-resolution adapter changes the process under test. + +The complete revised workflow consumer block executes before collection through +the same helper: both actual Git shells prove the selected Python interpreter +and Node 24.19.0 executable identity. Source (682 Python files), 197 dependencies +and original coverage remain unchanged during this proof; the owned forwarding +shim is removed. Shared helper/test files measure 56/161 lines, hooks retain +1,600 and skillgen 222. Both workflows pass actionlint 1.7.12; all extracted +Bash/Python blocks compile, and complete native blocks parse in Windows +PowerShell 5.1 and pwsh 7.6.6. CI measures 467 within its documented 470 ceiling. +These are local consumer/configuration results; corrected hosted proof remains +assigned to the next normal PR event. + +### Native artifact report retention — INC-CORE-09 + +Acceptance: REQ-CORE-004-AC03. Real source `f699b0f` native job +`111592305005` passes **836 tests, 21 explicit platform/baseline skips**, zero +failures/errors/warnings. JUnit individually passes all eighteen CORE-08 helper +cases, the original 33 shell regressions, all seven CLI cases and five CORE-07 +selection cases. Artifact `11321839435` archive SHA-256 +`634462cf980eaa47090d2d64109702473d6142678ccb655bbb4a0f8db3a8f51f` +matches GitHub's API digest. Source/base/synthetic checkout, tool versions, +197 dependency identities, pytest exit and source integrity are retained. +The real archive omits the generated `shell-consumers.json`; exact per-consumer +identity transport therefore remains unavailable despite passing preflight. + +The one-line artifact inclusion correction leaves producer/test behavior and +permissions unchanged. Its meaningful system regression is the actual GitHub +archive read and report/profile identity verification described in +[the acceptance procedure](../docs/COMPATIBILITY.md#native-consumer-evidence-retention--inc-core-09). +This is a real external publication boundary, not a simulated or literal-only +pytest test. Prepared configuration is not a retained-report pass. The corrected +normal PR event must provide exact source/base/checkout and archive/record proof +before AC03 is verified; current intended runs finish before publication. + +### Hosted source and optional-wheel checkpoint — f699b0f + +[CI 37255744156](https://github.com/SlinkyRamey/graphify/actions/runs/37255744156) +and [QML 37255744181](https://github.com/SlinkyRamey/graphify/actions/runs/37255744181) +are normal PR attempt-one runs. Source head +`f699b0feb760ff0fc6347b3930cf79fa0b4a615f`, base +`f61827f42378f6e7feff05a1cb4cbbcab1736ca8` and actual synthetic checkout +`114824c6099ce6592f7fd025a291b129681bb445` are bound through API parent inspection, +job logs and retained source identities. Both runs are terminal before the next +correction is published; no duplicate dispatch or job retry is introduced. + +| Profile | Actual outcome | Evidence boundary | +| --- | --- | --- | +| Ubuntu Python 3.12/3.13/3.14 | Each 9,007 passed, 112 explicit skips, zero failures; end-to-end install passes | Full source suite, real disposable FalkorDB and reviewed source wheel | +| Ubuntu Python 3.10 | 9,007 passed, 111 explicit skips, one cleanup-fixture failure; end-to-end install step omitted after failure | `test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence` does not inject its intended unlink failure on this interpreter | +| Native Windows Python 3.12 | 836 passed, 21 explicit skips, zero failures/errors/warnings | JUnit/integrity/archive evidence above; consumer report omission remains INC-CORE-09 | +| Optional QML wheel, four Windows lanes | Each 2,323 passed, three optional-extra skips | Platform source selection and unchanged isolated installed QML/core smokes | +| Optional QML wheel, four macOS lanes | Each 2,271 passed, 55 Windows/optional-extra skips | Platform source selection and unchanged isolated installed QML/core smokes | +| Optional QML wheel, four Ubuntu lanes | Each three artifact tests passed, zero skips | Unchanged isolated installed QML/core smokes also pass | + +All 39 assigned co-owner/remap/discovery/CLI source cases and the exact two live +FalkorDB plus three wheel cases individually pass on every Ubuntu lane. All four +downloaded archives match their API digests. Each 182-payload wheel matches the +reviewed source Git blobs; checkout, tracked inputs, dependencies and wheel bytes +remain unchanged. Both test graphs are deleted and all four owned containers are +confirmed absent. The service has no mounted storage, Redis 8.10.2 and module +60001; readiness includes actual graph-list/query operations. Source runner tools +are Node 22.23.3, GNU cpp 13.3.0, Bash 5.2.21 and Git 2.55.0 on Ubuntu image +20260927.320.1. Exact skip identities and per-case JUnit remain in retained source +artifacts; an aggregate count does not turn a skip into acceptance. + +All twelve QML jobs pass, including all 24 installed smokes. Its platform suites +run from the checkout and do not establish isolated installed origin for every +case. That workflow retains no wheel-digest/JUnit artifact; logs supply selected +module outcomes, not a fabricated per-case attestation. Windows/macOS Python 3.10 +each retain two existing warnings. Skill generation passes all five validators +and 134 generated artifacts. Advisory security remains green by policy while +Bandit reports four High/eight Medium/109 Low findings and completed pip-audit +reports nine unique package/advisory identities across three packages. Their +baseline origin is not assessed; this is not clean security acceptance. Local +Pyright still fails with 604 exact baseline errors and zero added/removed errors. + +### Partial-setup cleanup fixture portability — INC-QML-48 + +Acceptance: REQ-QML-012-AC02 and REQ-QML-018-AC06. Ordinary collected regression: +`tests/test_publication.py::test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence`. +The actual hosted Python 3.10 case fails before correction; native Python 3.10.21 +reproduces that failure against unchanged production/test bytes, while native +Python 3.12.14 passes. Runtime inspection confirms the cached accessor bypass. + +The corrected scenario patches production's actual `Path.unlink` entry point, +delegates real removal outside the exact rejected owned recovery target, and +requires that target's observed fault. Existing `GRAPH_PUBLICATION_CLEANUP`, public +message redaction, four accepted-product bytes and durable old-graph snapshot +assertions remain intact. Production publication and diagnostics do not change; +no resolver/schema/cache evidence is invalidated by this fixture correction. +Corrected ordinary native execution uses: + +```text +PYTHON -X utf8 -m pytest tests/test_publication.py tests/test_qt_product_publication.py tests/test_qt_readonly_publication.py tests/test_qt_publication_stub_origin.py -q -ra --tb=short -p no:cacheprovider --junitxml=REPORT.xml +``` + +Python 3.10.21/3.12.14/3.13.15/3.14.7 each pass **82 of 83 collected cases**, with +one explicit actual file-symlink capability skip and zero failures/errors. Each +includes all 32 publication cases and 51 adjacent Qt publication controls; the +corrected cleanup case individually passes without a skip. Source production +origin is verified. Dependency inventories (43/197/40/40 respectively), production +and selected test bytes, original coverage and checkpoint HEAD remain unchanged +through the run. A separate byte-exact `f699b0f` fixture replay reproduces the old +3.10 failure against the same production boundary. Distinct retained red/fixed-stage +logs bind each run to its actual test bytes. + +The final test has 297 physical lines and retains all four original assertion +ASTs, adding exact owned-target and exception-cause checks. Targeted Ruff 0.15.14 +and Pyright 1.1.409 report zero diagnostics. Only this test file changes Python +bytes from the prior exact 604-error baseline comparison; its baseline/current +diagnostic sets are empty. Whole-project typing remains a failed baseline gate. +Document checks resolve all 99 criteria and exact references; AST-only graph +refresh completes. Production, dependency/wheel, error-catalog and architecture +contracts are unchanged, so no new artifact payload or schema is introduced. +Corrected four-lane hosted execution and retained-report transport pass at the +verified checkpoint below. Local counts and skips alone do not establish them. + +### Verified hosted runner checkpoint — 10cb15a + +[CI 37257724886](https://github.com/SlinkyRamey/graphify/actions/runs/37257724886) +and [QML 37257724896](https://github.com/SlinkyRamey/graphify/actions/runs/37257724896) +are terminal successful normal PR attempt-one runs. Source +`10cb15a028312f21a169760e5f059a8f4ddfbfba`, base +`f61827f42378f6e7feff05a1cb4cbbcab1736ca8` and actual tested checkout +`697437305a9f8959ed6022fad3c676b4714caa0e` match API parent inspection, all source +identities and job logs. These results verify recorded profiles; they do not +promise unexecuted device events, unrestricted Qt SDK semantics or later revisions. + +| Executed profile | Passed | Explicit skips | Failures/errors | +| --- | --- | --- | --- | +| Full Ubuntu Python 3.10.22 | 9,008 | 111 | 0 | +| Full Ubuntu Python 3.12.3/3.13.16/3.14.8, each | 9,007 | 112 | 0 | +| Native Windows Python 3.12 | 836 | 21 | 0 | +| Four Windows QML source lanes, each | 2,323 | 3 | 0 | +| Four macOS QML source lanes, each | 2,271 | 55 | 0 | +| Four Ubuntu QML artifact lanes, each | 3 | 0 | 0 | + +All 40 assigned Linux cases (the prior 39 plus +`tests/test_publication.py::test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence`) +and the exact two live FalkorDB/three wheel cases are individually present and +passed in every source lane. Each complete job also passes end-to-end installation. +All four archive digests match the API. Every 182-payload wheel matches the tested +Git blobs; source/configuration, dependencies and wheel bytes are retained. +Dependency counts are 185/195/174/174 for Python 3.10/3.12/3.13/3.14. Both test +graphs are deleted and every pinned job-owned service is confirmed absent, with +zero mounts and verified image/readiness identity. + +Native artifact `11323730916` SHA-256 +`98e8645f9fd208b6502c62ea687fa83c81b48c6e190d9698ce62b85ffe1977b4` +matches its API digest and contains the actual 412-byte `shell-consumers.json`. +Both admitted Git Bash/sh executables identify the selected checkout Python and +Node 24.19.0, agreeing with the real preflight's same-file checks and job/profile +identity. All 18 helper, 33 former shell-failure, seven CLI and five tool-selection +cases pass individually; 197 dependencies, source inputs and checkout stay +unchanged. Retained JUnit, pytest exit, archive identity and upload completion +close INC-CORE-09's evidence gap; a workflow literal is not that proof. + +All twelve optional-wheel jobs and all 24 isolated installed QML/core smokes pass. +Its platform suites remain source profiles with logs rather than per-case JUnit, +wheel-digest/source attestation or dependency-integrity artifacts. Skip contracts +are unchanged: Windows has two optional MCP cases and one optional SVG case; +macOS additionally has 28 Windows-readonly and 24 Windows-native-alias cases. +Windows/macOS Python 3.10 each retain two existing warnings. Parser/dependency +identities and grouped skips are retained in job logs; unavailable evidence is +not converted into individual-case or isolated-origin claims. + +All five skill validators/134 generated artifacts pass. Advisory security +continues to report four High/eight Medium/109 Low Bandit findings and nine unique +pip-audit package/advisory identities across three packages; both scans exit one +under the existing advisory policy. Local full typing retains 604 matched baseline +errors with zero added/removed, while the changed test's diagnostic set is empty. +These contribution/system limits remain distinct from the passing test profiles. + +Published test Git blob `66dec42c98e1d1f52d736e36cb369e3f5acf5000` has LF SHA-256 +`285a9561f3262e3f75f77e893132bfcc3fb59d5a00ea6ed019b2a2e75a795886`; +the native tested file has SHA-256 +`27f0b19efc055039b1b71339432fb8c2d396de4aa8de9a440c6610cdd313953d`. +Only two CRLF endings differ. All 682 tracked Python files match tested content +exactly or by CRLF-to-LF normalization, with no unmatched file. Production Git +payloads are unchanged from `f699b0f`. CI blob +`66a508759924cb222adee7010156a092c2f2b37a` and QML-workflow blob +`fc432d5a747483f1dcb10de88e768eda91ef1b44` bind the tested configuration. +This completion edits documentation only; later review revisions retain their +own normal PR validation and recorded SHA. No new reproduced defect appears in +the exit review; INC-QML-49 remains unallocated and no merge/deployment occurs. + +### Manual discovery and native fixture contracts — INC-QML-46/47 + +The `38bf8cc` hosted Linux older-policy cases fail because the fixture omits +followed-directory discovery. The real manual parser separately rejects that +option with exit 2. INC-QML-46 adds the explicit parser option and retains its +default false, while every alias upgrade entry point uses the same supported +profile. INC-QML-47 corrects only the short-parent parser-spy expectation under +the already accepted INC-QML-45 long-spelling contract. Exact lexical symlink +ownership and every existing cold/warm/native/ID/retention assertion remain. + +| Acceptance ID | Exact ordinary regression | Observable outcome | +| --- | --- | --- | +| REQ-QML-011-AC01/AC04 | `tests/test_qt_update_discovery_profile.py::test_req_qml011_ac01_ac04_cli_default_and_optin_reach_real_publication` | Actual parser and nested rebuild receive false/true respectively and publish identical facts/products when corpus scope is unchanged | +| REQ-QML-011-AC01 | `tests/test_qt_update_discovery_profile.py::test_req_qml011_ac01_optin_cannot_discover_foreign_qt_provider`; `tests/test_qt_update_discovery_profile.py::test_req_qml011_ac01_cli_help_exposes_supported_optin` | Real foreign alias cannot admit a native provider; actual help exposes the option and default | +| REQ-QML-011-AC03 | `tests/test_qt_update_discovery_profile.py::test_req_qml011_ac03_invalid_cli_profile_refuses_before_rebuild_or_publication` | Four actual unknown-option/multiple-root orderings reject before rebuild and retain accepted graph/state/cache bytes | +| REQ-QML-020-AC03; REQ-QML-011-AC03/AC04 | `tests/test_source_alias_provenance.py::test_req_qml020_ac03_policy19_products_refresh_retain_and_recover` | Real manual/watch opt-in refreshes the old-policy cohort; staged failure retains products/stamp, then repair/full parity/repeat succeeds | +| REQ-QML-020-AC03 | `tests/test_source_alias_provenance.py::test_req_qml020_ac03_directory_symlink_requires_explicit_discovery_profile` | Real POSIX directory link remains excluded by default and appears only with explicit discovery; native Windows cannot supply this profile | +| REQ-QML-020-AC01 | `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac01_real_parent_alias_preserves_smoke_and_cold_warm_identity` | Exact native long-parent or lexical symlink parser inputs, warm unrelated cache, graph/IDs/membership parity and unchanged source bytes | + +Source command: environment Python `-X utf8 -m pytest` with the focused seven +cohort/discovery modules, `-q --tb=short -p no:cacheprovider`, retained JUnit. +It passes **70 cases, two actual file-symlink/POSIX-profile skips**; all seven +new CLI cases and both upgrade operations pass. Broader CLI/watch compatibility +passes **253 cases, five existing capability/profile skips**. The separate +membership source module passes **nine cases, seven actual file-symlink skips**, +with one existing Hypothesis collection warning. These overlapping profiles are +not combined into a full-suite total. + +Fresh wheel SHA-256: +`52a9c79c59ca2c1f985b3900655a6acf9f55b866b616e3d7f33d7767c17e449a`. +All 182 Python payloads equal source and installation; only CLI/help differs +from the preceding `c815eef8` wheel. Unchanged isolated QML/core and actual full- +TEMP short-alias smoke pass. Twelve ordinary installed modules run through +isolated `-I -X utf8` Python, `pytest --import-mode=importlib -p no:cacheprovider`, +with a retained JUnit report and an offline audit guard. Only exact owned native +junction fixture setup is admitted; analyzed code and network execution remain +forbidden. Result: **118 passed, nineteen explicit skips, zero failures/errors/ +warnings**, 43.80 seconds pytest. Eighteen skips are actual file-symlink capability +exclusions; one is the POSIX default-discovery profile. All 69 assigned new/prior +co-owner/spelling/CLI/upgrade/native warm-input cases pass individually with no +skip. All 154 loaded Graphify modules come from that artifact. Source (682 Python +files), source 197/optional 40/core 30 dependency versions and original coverage +are retained; owned fixtures are removed. + +Ruff 0.15.14 passes targeted/full source checks. Pyright 1.1.409/Python 3.12.14 +reports **604 errors, zero warnings, 678 files**. The fresh immutable `135d50b` +baseline has 604 matching errors across 658 files: zero added or removed. +Ten touched targets retain two exact existing errors, with zero new/removed. +Typing remains a failed gate. Current CI consumer/configuration proof is recorded +above; repository AST-only refresh completes with 22,641 nodes/51,218 edges and +the existing deliberately malformed Luau fixture warning. No further local gap +is reproduced; the corrected normal PR event owns hosted acceptance. + +### Discovered source identity — INC-QML-42 + +Hosted f95366d fails the unchanged original tests +`tests/test_cache.py::test_warm_cache_keeps_target_and_symlink_sources_distinct` +and `tests/test_watch.py::test_rebuild_code_incremental_rename_preserves_symlink_source_path` +in both completed Linux pytest profiles. The correction preserves their assertions; +actual Linux replay remains assigned to the corrected hosted run. Local actual +NTFS directory junctions reproduce six initial failures with one passing control +and one unavailable file-symlink case; stronger dual-alias assertions also expose +the shared prefix/stem lookup collapse and remain ordinary regressions. + +| Acceptance ID | Exact ordinary test | Source/installed checkpoint evidence | +| --- | --- | --- | +| REQ-QML-020-AC01 | `tests/test_source_alias_provenance.py::test_req_qml020_ac01_discovered_directory_sources_survive_cold_warm_and_reload`; `tests/test_source_alias_provenance.py::test_req_qml020_ac01_leaf_alias_retains_its_source_below_an_aliased_root`; `tests/test_source_alias_provenance.py::test_req_qml020_ac01_native_definition_keeps_discovered_directory_provenance` | Real target/directory aliases and aliased scan root retain exact disjoint IDs/call endpoints, warm cache fragments, directed/undirected JSON reload and C++ definition provenance; leaf symlink remains a host capability exclusion | +| REQ-QML-020-AC02 | `tests/test_source_alias_provenance.py::test_req_qml020_ac02_foreign_alias_cannot_acquire_native_root_authority`; `tests/test_source_alias_provenance.py::test_req_qml020_ac02_failed_parent_anchor_keeps_only_accepted_physical_identity` | Foreign linked QObject remains excluded with exact QT_CPP_ROOT, unchanged source bytes and no native authority; failed lexical anchors retain only the accepted physical source | +| REQ-QML-020-AC03 | `tests/test_source_alias_provenance.py::test_req_qml020_ac03_watch_rename_deletion_and_repeat_preserve_discovered_source`; `tests/test_source_alias_provenance.py::test_req_qml020_ac03_policy19_products_refresh_retain_and_recover` | Actual two renames/deletion/no-change graph bytes, no-edit policy19-to20 refresh, staged publication failure/cohort retention, repair/full parity and repeat pass | + +The full new selection passes **nine cases with one actual file-symlink skip**, +3.89 seconds. Existing cache/watch/ID/barrel/native/callback and alias/update +compatibility passes **405 cases with 31 capability skips**, 54.8 seconds. +The pure helper covers fifteen of fifteen measured statements and four of four +branches; isolated coverage storage preserves the original file. Facade/helper/ +policy/test physical sizes are 8,998/31/114/277, within existing ceilings. Policy +20 refreshes older native/Qt products; AST schema 12 remains unchanged. Single-alias +shared-content notification is separately reproduced and assigned INC-QML-44. + +### Nearby alias fixture — INC-QML-43 + +REQ-QML-020-AC01 additionally maps to +`tests/test_qt_nearby_alias_fixture.py::test_req_qml020_ac01_real_short_ancestors_preserve_nearby_facade_provenance`. +The original real five-ancestor fixture records one setup error with ten ancestor +hops; the corrected case passes with one real short-basename hop and unchanged +production source/edge equality. Existing/new alias modules pass **eight cases, +seven actual symlink capability skips**, one existing Hypothesis collection +warning. The source files measure 241 and 59 physical lines. No production +assertion, native SDK or graph/cache schema changes. + +### Installed discovered-alias checkpoint — INC-QML-42/43 + +Wheel SHA-256: +`65a096a0042b181582f179fd63c1108479822bac866f7fab0d3eaf5fb7730309`. +All 181 source/installed Python payloads match; only facade, policy and new +source_identity differ from the preceding f2 wheel. The isolated unchanged +optional/core smoke and actual full-TEMP Windows short-alias smoke pass, with +129/123 loaded artifact modules. The five-module installed contract selection +passes **43 cases, eleven actual file-symlink capability skips, zero failures/ +errors/warnings**, 13.58 seconds. All 153 loaded Graphify modules originate in +the artifact. The new source-identity module passes nine/one; the nearby-fixture +case passes. These overlapping counts are not added to full-suite totals. + +Offline harness setup admits only ten exact seven-argument native cmd/mklink/J +fixture operations within its verified owned temporary tree. Sixty other process +attempts remain denied and no network call is admitted; owned junctions/temp data +are confirmed removed. This exception establishes real fixture setup, not corpus +execution authority. All 675 Python-file fingerprints, source197/optional40/core30 +dependency versions, HEAD3c70bfc and original coverage bytes remain unchanged. +Separate source CI tool tests pass five cases without skips or warnings. + +Full Pyright remains **604 errors, zero warnings, 671 files**, exit one, 20.651 +seconds; every identity matches immutable135d50b under the same dependencies and +configuration, with zero added/removed. Targeted seven-file Pyright retains only +120 matched legacy-facade errors; the other six have zero diagnostics. Ruff passes. +This checkpoint excludes the subsequent INC-QML-44/45 correction and does not +establish corrected hosted or clean typing acceptance. + +### Shared physical co-owner watch invalidation — INC-QML-44 + +Initial production probes reproduce stale facts after only one of two admitted +source owners is notified. Real junction and hardlink regressions retain full/ +incremental equality assertions; the directory-rename case also exposes a shared +absolute-ID remap collision. The extractor owner corrects that collision alongside +INC-QML-45, without selecting source ownership through iteration order. + +| Acceptance ID | Exact ordinary production test | Assigned boundary | +| --- | --- | --- | +| REQ-QML-011-AC01/AC03; REQ-QML-020-AC03 | `tests/test_watch_physical_coowners.py::test_req_qml011_ac01_ac03_qml020_ac03_one_owner_edit_refreshes_shared_content` | Actual generic/mixed directory-alias and hardlink edits; disjoint stable APIs, stale removal, unrelated facts, cold/warm/full/repeat | +| REQ-QML-011-AC03; REQ-QML-012-AC03 | `tests/test_watch_physical_coowners.py::test_req_qml011_ac03_normal_rename_and_deletion_retire_only_absent_owners` | Existing detection/deletion behavior, both owner kinds and full/incremental equality | +| REQ-QML-012-AC01/AC02 | `tests/test_watch_physical_coowners.py::test_req_qml012_ac01_ac02_identity_failure_retains_recovers_and_repeats` | Five actual admitted resolve/stat/disappearance failures, safe diagnostic, prior four-product cohort and existing cache bytes, repair/repeat | +| REQ-QML-012-AC04 | `tests/test_watch_physical_coowners.py::test_req_qml012_ac04_unsafe_or_long_missing_identity_context_is_empty`; `tests/test_watch_physical_coowners.py::test_req_qml012_ac04_context_quotes_delimiters_and_preserves_160_character_boundary` | Empty unsafe/unavailable context, JSON delimiter quoting and 160/161 limits | +| REQ-QML-011-AC01 | `tests/test_watch_physical_coowners.py::test_req_qml011_ac01_selection_cannot_discover_foreign_or_unadmitted_coowners`; `tests/test_watch_physical_coowners.py::test_req_qml011_ac01_missing_inode_identity_uses_contained_path_without_false_joins`; `tests/test_watch_coowner_admission.py::test_req_qml011_ac01_semantic_backed_hardlink_keeps_its_own_tier`; `tests/test_watch_coowner_admission.py::test_req_qml011_ac01_excluded_hardlink_cannot_reenter_the_corpus` | Foreign/unadmitted/nonregular rejection, conservative inode fallback, retained semantic tier and ignore exclusions | +| REQ-QML-011-AC03/AC04; REQ-QML-020-AC03 | `tests/test_watch_physical_coowners.py::test_req_qml011_ac03_ac04_qml020_ac03_policy20_refresh_retains_repairs_and_repeats` | Actual unchanged policy20-to21 watch refresh, staged-stamp failure, retained cohort and repaired full/repeat parity | + +The new stateless helper measures 69 lines, facade watch 2,543 within its 2,550 +ceiling, and focused tests 280/70. Isolated helper coverage measures 39 of 39 +statements and ten of ten branches; original coverage is untouched. Combined +source/installed results appear below; corrected hosted acceptance is pending. Early +scan/stat bookkeeping remains outside the prior-product retention boundary; +cache-byte assertions establish the actual rejection point rather than a universal +promise about detection bookkeeping. + +### Native entry spelling and shared ID ownership — INC-QML-45 + +Actual Windows short header/build-metadata spellings reproduce four membership/ +source-identity failures with one passing foreign-root control. Admission now +precedes worker/cache work and verifies a native long spelling of the same file +without resolving discovered directory owners. The remap correction separates +primary walked claims from shared resolved forms; canonical supplied ownership, +unique-alias fallback and ambiguous physical forms retain distinct contracts. + +| Acceptance ID | Exact ordinary production test | Assigned boundary | +| --- | --- | --- | +| REQ-QML-020-AC01 | `tests/test_qt_short_leaf_aliases.py::test_req_qml020_ac01_short_native_leaf_preserves_membership_facts_and_reload`; `tests/test_qt_short_leaf_aliases.py::test_req_qml020_ac01_native_utf16_lengths_preserve_astral_source_names` | Real header/build-metadata/both short inputs, exact raw facts, membership, original BOM/CRLF bytes/spans, UTF-16 supplementary names, cold/warm and directed reload | +| REQ-QML-020-AC02 | `tests/test_qt_short_leaf_aliases.py::test_req_qml020_ac02_short_spelling_preserves_two_discovered_junction_owners`; `tests/test_qt_short_leaf_aliases.py::test_req_qml020_ac02_short_foreign_native_leaf_keeps_existing_root_rejection`; `tests/test_source_identity_remap_owners.py::test_req_qml020_ac02_primary_physical_owner_survives_both_batch_orders`; `tests/test_source_identity_remap_owners.py::test_req_qml020_ac02_multiple_aliases_cannot_authorize_arbitrary_physical_target` | Separate junction IDs/calls, foreign native rejection, canonical-first/last absolute/relative inputs and conservative ambiguous import ownership | +| REQ-QML-012-AC01/AC02; REQ-QML-020-AC01/AC03 | `tests/test_source_input_admission.py::test_req_qml012_ac01_ac02_qml020_ac01_native_failure_retains_repairs_and_repeats`; `tests/test_source_input_admission.py::test_req_qml012_ac01_native_admission_refuses_before_any_worker_or_cache_write`; `tests/test_source_input_admission.py::test_req_qml012_ac01_native_filesystem_identity_failure_is_bounded` | Actual zero/oversize/empty/wrong-length/foreign-target/backend API failures, stat/same-file refusal, manual/watch prior products and cache bytes, repair/full parity/repeat, direct refusal before work | +| REQ-QML-020-AC01 | `tests/test_source_input_admission.py::test_req_qml020_ac01_missing_and_nonfile_keep_existing_admission_shape`; `tests/test_source_input_admission.py::test_req_qml020_ac01_nonwindows_input_needs_no_native_lookup` | Existing missing/directory/non-file handling and no POSIX native API requirement | +| REQ-QML-012-AC04 | `tests/test_source_input_admission.py::test_req_qml012_ac04_native_context_is_quoted_bounded_and_lexical`; `tests/test_source_input_admission.py::test_req_qml012_ac04_failed_context_lookup_keeps_bounded_admission_error` | Delimiters, empty/control/160–161 context, actual API failure plus failed lexical/CWD context adapter | + +An early ambiguous-control expectation excluded all builder stubs. The owning +contract instead rejects newly admitted source/native authority and arbitrary +alias selection; ordinary unresolved import stubs keep the upstream builder's +existing behavior. Raw producer facts and built stub roles are verified separately. +No graph-builder behavior or accepted-source policy is changed for that control. +The combined local results are recorded below; corrected hosted proof is pending. + +### Combined source and installed checkpoint — INC-QML-44/45 + +Native Windows/Python 3.12.14 source selection: +**640 passed, 21 capability/profile skips, zero failures**, 114.30 seconds. +All 59 new cases above execute without skips. The retained exclusions are eight +cache file-symlink cases, two active-CWD removals, one Windows Git profile, +one watch directory-symlink setup, one watch file-symlink, four extractor +file-symlinks, one discovered leaf-symlink and three prior diagnostic-context +symlinks. Actual Linux/platform proof is separately assigned to hosted execution. +Focused new selections overlap this profile and are not added to its total. + +Fresh wheel SHA-256: +`c815eef893aa928eb58287be2b56e41c22b99ba43999e2c3ec30026af82108ac`. +All 182 source/wheel/installed Python payloads match, with only the approved five +payload changes from the 65a096a checkpoint. Unchanged QML/core/full-TEMP short +smokes pass; 129/123 loaded smoke modules come from the artifact. The ten-module +installed profile passes **102 cases, eleven actual file-symlink skips, zero +failures/errors/warnings**, 34.49 seconds; all 154 loaded Graphify modules +originate in the artifact. All 59 new cases pass. Only 33 exact owned native +junction-fixture callbacks are admitted; 157 other process attempts remain +denied, with no additional process/network authority. Temporary junctions/data +are confirmed removed. Source fingerprints for 681 Python files, 197/40/30 source/ +optional/core dependencies, basis HEADfca23e2 and original coverage stay unchanged. +Separate actual Windows CI tool tests pass five cases, no skips/warnings. + +Source identity coverage: 76/76 statements and 22/22 branches; its broader control +selection passes 54 cases with eight capability skips. Watch co-owner coverage: +39/39 statements and 10/10 branches; all 21 ordinary cases pass. Both use isolated +coverage storage. Whole-project Pyright 1.1.409 retains **604 errors, zero +warnings, 677 files**, exactly matching immutable135d50b diagnostic identities +under the same configuration/dependencies: zero added/removed. Targeted ten-file +typing retains only 142 matched legacy facade/watch errors; other eight have zero. +Ruff 0.15.14 and 134 generated artifacts pass. Actionlint 1.7.12 reports no +workflow diagnostics; both complete native run blocks parse with zero errors in +Windows PowerShell 5.1 and pwsh 7.6.6. Workflow size remains 450 lines. +AST-only `graphify update . --no-cluster` completes with no LLM invocation; +graph counts are navigation output rather than correctness evidence. Current +hosted, typing and physical device/system gaps remain explicit. + +## Individual acceptance assignments + +The [follow-up audit](../docs/qt-qml/FOLLOWUP_AUDIT.md) supplies counterexamples for +REQ-QML-008-AC01/AC03, REQ-QML-016-AC01–AC04 and REQ-QML-017-AC02/AC03/AC04. The table's historical +passes do not cover those cases. Current INC-QML-17–20 correction evidence appears +below; affected criteria retain explicit expanded-profile gaps. +Exact opt-in probes and corrective increments appear in +[scope corrections](#follow-up-audit-scope-corrections). + +Exact test references below identify executed cases. Documentation, privacy and +hosted integration reviews are explicit review evidence rather than invented unit +tests. Later revisions reverify affected evidence; preserve these identifiers. + +The INC-QML-06/07 rows retain their original bounded-profile evidence. Expanded +shared-owner incremental contracts in REQ-QML-011-AC01/AC03/AC04 are assigned to +INC-QML-44; new input-identity failure and short-leaf contracts are assigned to +INC-QML-45. Historical passes do not verify those additions. Their current +source, installed and hosted results are recorded separately below when run. + +| Acceptance ID | Actual evidence / assigned open case | Planned completion increment | Status | +| --- | --- | --- | --- | +| REQ-QML-001-AC01 | `tests/test_qml_wheel_artifact.py::test_qml001_ac01_built_wheel_contains_adapter_and_optional_extra_metadata`; `tests/test_qml_wheel_artifact.py::test_qml001_ac01_ac03_built_artifact_production_import_and_parser_boundary` | INC-QML-01 | Verified (declared profile) | +| REQ-QML-001-AC02 | `tests/test_qml_syntax_profile.py::test_qml001_ac02_handchecked_profile_uses_production_extractor_and_original_spans`; `tests/test_qml_syntax_profile.py::test_qml001_ac02_profile_runs_offline_in_fresh_production_process_without_corpus_execution` | INC-QML-03 | Verified (declared profile) | +| REQ-QML-001-AC03 | `tests/test_qml_failures.py::test_qml001_ac03_missing_optional_import_is_safe_and_does_not_break_python`; `tests/test_qml_failures.py::test_qml001_ac03_incompatible_parser_load_is_bounded_failure` | INC-QML-01 | Verified (declared profile) | +| REQ-QML-001-AC04 | `tests/test_qml_declarations.py::test_qml001_ac04_empty_source_is_distinguishable_from_parse_failure`; `tests/test_qml_declarations.py::test_qml012_ac01_malformed_or_partial_parse_has_no_authoritative_nodes`; `tests/test_qml_failures.py::test_qml001_ac04_unsupported_annotated_root_is_not_file_only_success` | INC-QML-01 | Verified (declared profile) | +| REQ-QML-002-AC01 | `tests/test_qt_config_incremental.py::test_qml011_ac03_import_root_order_refreshes_unchanged_source_provider; tests/test_qml_identity.py::test_qml01_ui_suffix_names_component_without_losing_filename_identity` | INC-QML-06 | Verified static source profile | +| REQ-QML-002-AC02 | `tests/test_qt_metadata_admission.py::test_cmake_exact_name_does_not_reclassify_arbitrary_text; tests/test_qml_integration.py::test_qml002_ac02_named_qmldir_dispatch_directory_and_single_file_root` | INC-QML-06 | Verified static source profile | +| REQ-QML-002-AC03 | `tests/test_qt_metadata_admission.py::test_qt_metadata_discovery_obeys_existing_ignore_policy; tests/test_qt_resource_resolution.py::test_qrc_host_reads_entities_traversal_and_malformed_xml_rejected` | INC-QML-06 | Verified static source profile | +| REQ-QML-002-AC04 | `tests/test_qml_identity.py::test_qml003_ac04_relocated_root_and_cwd_preserve_all_facts; tests/test_qt_config_incremental.py::test_qml011_ac03_import_root_order_refreshes_unchanged_source_provider` | INC-QML-06 | Verified static source profile | +| REQ-QML-003-AC01 | `tests/test_qml_declarations.py::test_qml003_ac01_exact_declarations_ownership_and_raw_types`; `tests/test_qml_declarations.py::test_qml003_ac01_unicode_crlf_spans_and_original_names`; `tests/test_qml_syntax_profile.py::test_qml003_ac01_property_binding_and_array_objects_keep_exact_owners` | INC-QML-01 | Verified (declared profile) | +| REQ-QML-003-AC02 | `tests/test_qml_identity.py::test_qml003_ac02_duplicate_basename_uses_full_relative_path`; `tests/test_qml_identity.py::test_qml003_ac02_inline_component_equal_ids_and_members_have_separate_owners`; `tests/test_qml_graph.py::test_qml003_ac02_same_stem_cpp_js_and_qml_do_not_merge`; template barrier cases in test_qml_syntax_profile.py | INC-QML-01 | Verified (declared profile) | +| REQ-QML-003-AC03 | `tests/test_qml_declarations.py::test_qml003_ac03_comments_literals_groups_and_js_inner_functions_are_not_objects` | INC-QML-01 | Verified (declared profile) | +| REQ-QML-003-AC04 | `tests/test_qml_identity.py::test_qml003_ac04_relocated_root_and_cwd_preserve_all_facts`; `tests/test_qml_identity.py::test_qml003_ac04_comment_insert_changes_spans_but_not_named_identity`; `tests/test_qml_integration.py::test_qml010_ac01_real_process_pool_and_warm_order_match_sequential` | INC-QML-01 | Verified (declared profile) | +| REQ-QML-004-AC01 | `tests/test_qml_resolution.py::test_aliased_directory_and_uri_imports_do_not_cross_bind`; `tests/test_qml_resolution.py::test_version_availability_and_latest_compatible_export` | INC-QML-02 | Verified (declared profile) | +| REQ-QML-004-AC02 | `tests/test_qml_resolution.py::test_aliased_directory_and_uri_imports_do_not_cross_bind`; `tests/test_qml_resolution.py::test_versioned_layout_and_missing_version_evidence` | INC-QML-02 | Verified (declared profile) | +| REQ-QML-004-AC03 | `tests/test_qml_resolution.py::test_competing_providers_and_missing_modules_have_no_target_edges` | INC-QML-02 | Verified (declared profile) | +| REQ-QML-004-AC04 | `tests/test_qml_resolution.py::test_declared_roots_and_remote_import_never_expand_corpus`; `tests/test_qml_resolution.py::test_directory_and_script_projection_and_ignored_disk_provider` | INC-QML-02 | Verified (declared profile) | +| REQ-QML-005-AC01 | `tests/test_qml_scope.py::test_component_ids_do_not_leak_between_files_or_inline_components` | INC-QML-02 | Verified (declared profile) | +| REQ-QML-005-AC02 | `tests/test_qml_scope.py::test_inline_shadow_and_inherited_members_are_distinct_roles`; `tests/test_qml_scope.py::test_singleton_pragma_and_qualified_access` | INC-QML-02 | Verified (declared profile) | +| REQ-QML-005-AC03 | `tests/test_qml_scope.py::test_internal_external_dynamic_and_lexical_members_stay_unresolved` | INC-QML-02 | Verified (declared profile) | +| REQ-QML-005-AC04 | `tests/test_qml_resolution.py::test_module_script_exports_have_separate_lookup_roles` | INC-QML-02 | Verified (declared profile) | +| REQ-QML-006-AC01 | `tests/test_qml_expressions.py::test_binding_reads_and_nested_qualified_aliases` | INC-QML-03 | Verified (declared profile) | +| REQ-QML-006-AC02 | `tests/test_qml_expressions.py::test_alias_cycles_missing_and_ambiguous_targets_never_guess`; `tests/test_qml_expressions.py::test_component_and_object_scope_do_not_bind_hidden_identifiers` | INC-QML-03 | Verified (declared profile) | +| REQ-QML-006-AC03 | `tests/test_qml_expressions.py::test_dynamic_reads_and_executable_source_are_only_analyzed` | INC-QML-03 | Verified (declared profile) | +| REQ-QML-006-AC04 | `tests/test_qml_expressions.py::test_repeated_sites_survive_actual_directed_build_and_json`; `tests/test_qml_integration.py::test_qml010_ac02_default_undirected_export_reload_keeps_qml_direction` | INC-QML-03 | Verified (declared profile) | +| REQ-QML-007-AC01 | `tests/test_qml_handlers.py::test_parameters_block_bindings_nested_functions_and_computed_calls`; `tests/test_qml_handlers.py::test_inherited_and_alias_target_signal_parameters_shadow_properties` | INC-QML-03 | Verified (declared profile) | +| REQ-QML-007-AC02 | `tests/test_qml_scripts.py::test_literal_imported_helpers_and_library_do_not_inherit_document_ids`; `tests/test_qml_scripts.py::test_mjs_explicit_exports_aliases_and_private_functions`; `tests/test_qml_scripts.py::test_accepted_script_dependencies_support_classic_and_esm_imports`; `tests/test_qml_scripts.py::test_script_overlay_retains_original_shared_js_nodes_and_never_executes` | INC-QML-03 | Verified (declared profile) | +| REQ-QML-007-AC03 | `tests/test_qml_handlers.py::test_declared_and_property_change_handlers_are_subscriptions`; `tests/test_qml_handlers.py::test_connections_target_and_dynamic_target_stay_distinct`; `tests/test_qml_adversarial.py::test_mixed_legacy_connections_handlers_do_not_activate_ignored_function_handlers` | INC-QML-03 | Verified (declared profile) | +| REQ-QML-007-AC04 | `tests/test_qml_scripts.py::test_generic_js_calls_cannot_bind_to_qml_owned_expression_sites`; `tests/test_qml_scripts.py::test_script_overlay_does_not_read_unaccepted_imports_or_network` | INC-QML-03 | Verified (declared profile) | +| REQ-QML-008-AC01 | `tests/test_qt_project_admission.py::test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints` | INC-QML-05 | Locally verified bounded alias registration correction; adoption gaps retained | +| REQ-QML-008-AC02 | `tests/test_qt_cpp_exposure.py::test_header_implementation_members_reuse_accepted_canonical_ids`; `tests/test_qt_project_admission.py::test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints`; `tests/test_qt_cpp_definition_ownership.py::test_req_qml008_ac02_definition_provenance_keeps_emission_owned_through_aggregate`; normalization, conflicting callable/body, missing completeness, external/local class, generic identity and original-byte controls in that module; `tests/test_qt_cpp_owner_upgrade.py::test_qml008_ac02_policy_three_reparses_unchanged_native_ownership` (update/extract); [current constructor/source-containment assignments](#constructor-and-source-containment-corrections) | INC-QML-05; INC-QML-10/12/13 corrections | Locally verified bounded ownership; qualified ID correction has separate INC-QML-21 evidence below | +| REQ-QML-008-AC03 | `tests/test_qt_native_project_integration.py::test_cpp_source_in_two_distinct_build_contexts_has_no_arbitrary_native_provider; tests/test_qt_qml_integration.py::test_ambiguous_overload_and_version_revised_member_are_explicit` | INC-QML-05 | Locally verified bounded alias rejection; broader adoption/overload gaps retained | +| REQ-QML-008-AC04 | `tests/test_qt_cpp_syntax.py::test_unicode_crlf_macro_spans_are_original_bytes; tests/test_qt_cpp_syntax.py::test_comments_strings_raw_literals_and_preprocessor_definitions_are_inert` | INC-QML-05 | Verified static source profile | +| REQ-QML-009-AC01 | `tests/test_qt_project_admission.py::test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints; tests/test_qt_resource_resolution.py::test_public_qt6_cmake_and_qmake_fixtures_describe_identical_membership` | INC-QML-05 | Verified static source profile | +| REQ-QML-009-AC02 | `tests/test_qml_types_metadata.py::test_qmltypes_members_flags_exports_original_byte_provenance; tests/test_qml_types_metadata.py::test_qmltypes_cpp_member_conflict_preserves_authoritative_source` | INC-QML-05 | Verified static source profile | +| REQ-QML-009-AC03 | `tests/test_qt_project_admission.py::test_qml009_ac03_public_metadata_qrc_load_build_export_reload; tests/test_qt_resource_resolution.py::test_qrc_host_reads_entities_traversal_and_malformed_xml_rejected` | INC-QML-05 | Verified static source profile | +| REQ-QML-009-AC04 | `tests/test_qt_project_metadata.py::test_cmake_conditional_and_expanded_metadata_is_not_authoritative; tests/test_qt_project_metadata.py::test_qmake_conditions_expansion_functions_never_produce_guessed_context` | INC-QML-05 | Verified static source profile | +| REQ-QML-010-AC01 | `tests/test_qt_worker_cache_integrity.py::test_qml010_ac01_actual_workers_preserve_configured_roots_script_and_native_facts`; `tests/test_qml_identity.py::test_qml010_ac01_filename_normalization_collisions_keep_distinct_ids`; `tests/test_qml_identity.py::test_qml003_ac02_unicode_normalization_colliding_members_remain_distinct`; `tests/test_qml_integration.py::test_qml010_ac01_real_process_pool_and_warm_order_match_sequential` | INC-QML-07 | Verified (bounded static profile) | +| REQ-QML-010-AC02 | `tests/test_qt_qml_export_consumers.py::test_json_repeated_native_mechanisms_remain_distinct_and_directional`; `tests/test_qt_qml_export_consumers.py::test_native_path_reports_qml_call_site_and_cpp_declaration_without_reversing_flow`; `tests/test_qt_graph_persistence.py` | INC-QML-07 | Verified (bounded static profile) | +| REQ-QML-010-AC03 | `tests/test_qt_qml_export_consumers.py::test_json_repeated_native_mechanisms_remain_distinct_and_directional`; `tests/test_qml_expressions.py::test_repeated_sites_survive_actual_directed_build_and_json`; `tests/test_qt_export_matrix.py::test_cypher_keeps_parallel_mechanisms_and_escapes_literal_data` | INC-QML-07 | Verified (bounded static profile) | +| REQ-QML-010-AC04 | `tests/test_qt_affected_definitions.py::test_qml016_ac04_signal_change_reports_canonical_out_of_line_function_owner`; `tests/test_qt_signals_slots.py::test_native_events_have_distinct_sites_and_no_delivery_calls`; `tests/test_qt_graph_persistence.py`; `tests/test_build.py::test_build_merge_sln_stub_does_not_replace_referenced_csproj`; `tests/test_build.py::test_build_merge_project_reference_stub_does_not_replace_referenced_project`; `tests/test_build.py::test_merge_raw_extraction_cross_file_stub_parity` | INC-QML-07 | Verified (bounded static profile) | +| REQ-QML-011-AC01 | `tests/test_qt_final_incremental_parity.py::test_qml011_ac01_real_normal_qml_edit_matches_cold_warm_manual_and_watch`; `tests/test_qt_metadata_incremental.py::test_native_cpp_provider_only_edit_refreshes_unchanged_qml_and_warm_overlay` | INC-QML-06 | Verified (bounded static profile) | +| REQ-QML-011-AC02 | `tests/test_qt_metadata_incremental.py::test_build_provider_only_mutations_equal_clean_accepted_corpus; tests/test_qt_metadata_incremental.py::test_resource_only_mutations_refresh_unchanged_cpp_loaders; tests/test_qt_config_incremental.py::test_qml011_ac02_last_qt_source_deletion_cleans_facts_and_commits_nonqt_state` | INC-QML-06 | Verified static source profile | +| REQ-QML-011-AC03 | `tests/test_qt_config_incremental.py::test_qml011_ac03_import_root_order_refreshes_unchanged_source_provider; tests/test_qt_config_incremental.py::test_qml011_ac03_package_version_change_reanalyzes_unchanged_corpus` | INC-QML-06 | Verified static source profile | +| REQ-QML-011-AC04 | `tests/test_qt_final_incremental_parity.py::test_qml011_ac04_real_no_change_updates_preserve_every_fact_and_unrelated_python`; `tests/test_qt_metadata_incremental.py::test_build_provider_only_mutations_equal_clean_accepted_corpus`; `tests/test_qt_metadata_incremental.py::test_resource_only_mutations_refresh_unchanged_cpp_loaders` | INC-QML-06 | Verified (bounded static profile) | +| REQ-QML-012-AC01 | `tests/test_qml_failures.py::test_qml001_ac03_missing_optional_import_is_safe_and_does_not_break_python`; `tests/test_qml_failures.py::test_qml012_ac01_native_parse_exception_is_explicit_safe_failure`; `tests/test_qml_resolver_safety.py::test_native_join_failure_is_guarded_and_successful_retry_is_clean` | INC-QML-06 | Verified (bounded static profile) | +| REQ-QML-012-AC02 | `tests/test_qt_worker_cache_integrity.py::test_qml012_ac02_failed_native_parser_keeps_prior_real_ast_cache_and_products`; `tests/test_qt_config_incremental.py::test_qml012_ac02_malformed_new_source_preserves_graph_manifest_and_stamp`; `tests/test_qt_metadata_incremental.py::test_failed_metadata_update_preserves_prior_graph_manifest_and_report` | INC-QML-06 | Verified (bounded static profile) | +| REQ-QML-012-AC03 | `tests/test_qt_config_incremental.py::test_qml011_ac02_last_qt_source_deletion_cleans_facts_and_commits_nonqt_state`; `tests/test_qml_watch_persistence.py::test_watch_qml_failure_preserves_completed_products_with_force`; `tests/test_qml_cli_persistence.py` | INC-QML-06 | Verified (bounded static profile) | +| REQ-QML-012-AC04 | `tests/test_qml_failures.py::test_qml012_ac04_deep_source_terminates_at_supported_bound`; `tests/test_qml_failures.py::test_qml012_ac04_explicit_root_rejection_does_not_expose_absolute_paths`; `tests/test_qt_resource_resolution.py::test_qrc_host_reads_entities_traversal_and_malformed_xml_rejected`; `tests/test_qt_qml_search.py::test_deep_malformed_literal_transport_cannot_crash_production_search` | INC-QML-06 | Verified (bounded static profile) | +| REQ-QML-013-AC01 | `tests/test_qt_query_consumers.py::test_cli_query_explain_and_path_use_source_scoped_nodes`; `tests/test_qt_qml_export_consumers.py::test_native_path_reports_qml_call_site_and_cpp_declaration_without_reversing_flow`; `tests/test_qt_mcp_consumers.py::test_mcp_exact_id_path_matches_cli_and_retains_source_evidence` | INC-QML-07 | Verified (bounded static profile) | +| REQ-QML-013-AC02 | `tests/test_qt_affected_consumers.py::test_property_change_reports_binding_and_owning_component`; `tests/test_qt_affected_definitions.py::test_qml016_ac04_signal_change_reports_canonical_out_of_line_function_owner`; `tests/test_qt_affected_consumers.py::test_future_metadata_or_foreign_file_ownership_is_not_dependency_evidence` | INC-QML-07 | Verified (bounded static profile) | +| REQ-QML-013-AC03 | `tests/test_qt_qml_export_consumers.py`; `tests/test_qt_export_matrix.py`; `tests/test_qt_graph_html_payload.py`; `tests/test_qt_html_consumers.py`; `tests/test_qt_source_coverage_report.py`; format-specific omissions and unexecuted live database behavior: [EXPORT_MATRIX](../docs/qt-qml/EXPORT_MATRIX.md) | INC-QML-07 | Verified (bounded static profile) | +| REQ-QML-013-AC04 | `tests/test_qt_mcp_consumers.py`; `tests/test_qt_mcp_stdio.py`; `tests/test_qt_qml_search.py` | INC-QML-07 | Verified (bounded static profile) | +| REQ-QML-014-AC01 | `tests/test_qml_wheel_artifact.py`; `tests/test_qml_platform_matrix.py`; `tests/qml_installed_smoke.py`; twelve exact-head optional/core wheel jobs: [PLATFORM_MATRIX](../docs/qt-qml/PLATFORM_MATRIX.md) | INC-QML-07 | Verified (declared hosted/static profile) | +| REQ-QML-014-AC02 | `tests/test_qml_identity.py::test_qml003_ac04_relocated_root_and_cwd_preserve_all_facts`; `tests/test_qt_cpp_syntax.py::test_unicode_crlf_macro_spans_are_original_bytes`; `tests/test_qml_wheel_artifact.py::test_qml001_ac01_ac03_built_artifact_production_import_and_parser_boundary` | INC-QML-07 | Verified (declared hosted/static profile) | +| REQ-QML-014-AC03 | `tests/test_extract.py`; `tests/test_cache.py`; `tests/test_serve.py`; `tests/test_export.py`; `tests/test_affected_cli.py`; `tests/test_callflow_html.py`; affected baseline full suites in four hosted Ubuntu Python lanes; local optional skips and Windows baseline defects remain in VALIDATION.md | INC-QML-07 | Verified (declared hosted/static profile) | +| REQ-QML-014-AC04 | Executed skip/baseline/typing accounting in [VALIDATION](../docs/qt-qml/VALIDATION.md); optional MCP executed through actual HTTP/stdio; absent SVG is an explicit omission | INC-QML-07 | Verified (declared hosted/static profile) | +| REQ-QML-015-AC01 | Requirement/68-criterion/link/status review and reviewable stacked PRs; [IMPLEMENTATION](../docs/qt-qml/IMPLEMENTATION.md), [PLAN](../docs/qt-qml/PLAN.md) | INC-QML-07 | Verified (declared hosted/static profile) | +| REQ-QML-015-AC02 | `tests/test_qml_skillgen_guidance.py`; `tests/test_skillgen.py`; five generator validators run separately; all 134 generated artifacts and expected outputs regenerated | INC-QML-07 | Verified (declared hosted/static profile) | +| REQ-QML-015-AC03 | Verified upstream v8/base/head and earlier upstream proposal read-only; preserved grammar attribution; exact PR head/base/tested merge and workflows recorded in [VALIDATION](../docs/qt-qml/VALIDATION.md) | INC-QML-07 | Verified (declared hosted/static profile) | +| REQ-QML-015-AC04 | Executed public privacy/reference/footprint review; all 68 criteria accounted for with explicit static/runtime/platform/export boundaries; [README](../docs/qt-qml/README.md), [EXPORT_MATRIX](../docs/qt-qml/EXPORT_MATRIX.md), [PLATFORM_MATRIX](../docs/qt-qml/PLATFORM_MATRIX.md) | INC-QML-07 | Verified (declared hosted/static profile) | +| REQ-QML-016-AC01 | `tests/test_qt_cpp_syntax.py::test_access_sections_keep_original_roles_offsets`; `tests/test_qt_signals_slots.py::test_native_events_have_distinct_sites_and_no_delivery_calls`; `tests/test_qt_signals_slots.py::test_macro_sections_private_meta_slots_and_comments`; `tests/test_qt_cpp_definition_ownership.py::test_req_qml016_ac01_forward_declaration_does_not_compete_with_complete_definition` | INC-QML-04b; INC-QML-10 correction | Partial; alias correction locally verified; inherited/qualified identity gaps INC-QML-11/21 | +| REQ-QML-016-AC02 | `tests/test_qt_signals_slots.py::test_overloads_need_selector_and_dynamic_sender_stays_unresolved`; `tests/test_qt_events_boundaries.py::test_functor_function_and_connection_handle_disconnect`; `tests/test_qt_events_boundaries.py::test_explicit_cast_signal_to_signal_and_condition_flags`; `tests/test_qt_events_boundaries.py::test_private_typed_pointer_and_incompatible_receiver_are_rejected`; [alias counterexamples](#follow-up-audit-scope-corrections) | INC-QML-04b; INC-QML-19 correction | Locally verified bounded aliases; generic overload gap INC-QML-15 retained | +| REQ-QML-016-AC03 | `tests/test_qt_events_boundaries.py::test_explicit_cast_signal_to_signal_and_condition_flags`; `tests/test_qt_events_boundaries.py::test_computed_signal_receiver_and_custom_connect_are_not_qt_targets`; `tests/test_qt_signals_slots.py::test_native_events_have_distinct_sites_and_no_delivery_calls`; [alias counterexamples](#follow-up-audit-scope-corrections) | INC-QML-04b; INC-QML-19 correction | Locally verified bounded alias/conditional rejection; unsupported forms retained | +| REQ-QML-016-AC04 | `tests/test_qt_event_incremental_consumers.py::test_qml016_ac04_event_mutation_and_removal_match_clean_rebuild_without_delivery_calls`; `tests/test_qt_html_consumers.py::test_signal_emission_is_not_rendered_as_a_caller_in_the_call_table`; `tests/test_qt_affected_definitions.py`; `tests/test_qt_mcp_consumers.py::test_qml016_ac04_http_metadata_search_keeps_connection_reference_semantics`; `tests/test_qt_worker_cache_integrity.py`; `tests/test_qt_cpp_owner_upgrade.py::test_qml016_ac04_failed_owner_upgrade_retains_prior_graph_stamp_and_manifest` (force off/on); persisted JSON and aggregate ownership in `tests/test_qt_cpp_definition_ownership.py` | INC-QML-07; INC-QML-10 correction | Partial; alias lifecycle/artifact evidence passes; inherited/qualified identity gaps retained | +| REQ-QML-017-AC01 | `tests/test_qt_project_admission.py::test_qml017_ac01_literal_module_load_reaches_declared_component_and_property; tests/test_qt_project_admission.py::test_qml009_ac03_public_metadata_qrc_load_build_export_reload` | INC-QML-05 | Locally verified bounded literal loader correction; omitted API families excluded | +| REQ-QML-017-AC02 | `tests/test_qml_cpp_access.py::test_view_root_and_literal_object_name_property_access`; `tests/test_qt_access_providers.py::test_qqmlproperty_read_write_preserves_property_handle`; `tests/test_qt_project_admission.py::test_qml017_ac01_literal_module_load_reaches_declared_component_and_property`; `tests/test_qt_cpp_definition_ownership.py::test_req_qml017_ac02_namespace_definition_owns_source_backed_qml_access`; [receiver counterexamples](#follow-up-audit-scope-corrections) | INC-QML-05; INC-QML-10/17 corrections | Partial; receiver correction passes; static reflection correction has separate INC-QML-22 evidence below | +| REQ-QML-017-AC03 | `tests/test_qt_access_providers.py`; `tests/test_qt_project_admission.py::test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints`; `tests/test_qt_native_project_integration.py`; [engine counterexample](#follow-up-audit-scope-corrections) | INC-QML-05; INC-QML-18 correction | Locally verified exact declaration/provider and component-engine profile; factory/member gap retained | +| REQ-QML-017-AC04 | `tests/test_qt_final_incremental_parity.py::test_qml017_ac04_qml_member_edit_refreshes_unchanged_reverse_cpp_access`; `tests/test_qml_cpp_access.py::test_duplicate_object_names_do_not_select_first_child`; `tests/test_qt_access_providers.py::test_duplicate_context_provider_and_local_shadow_do_not_choose`; `tests/test_qt_metadata_incremental.py`; `tests/test_qt_qml_export_consumers.py`; `tests/test_qt_mcp_stdio.py`; persisted export ownership in `tests/test_qt_cpp_definition_ownership.py::test_req_qml017_ac02_namespace_definition_owns_source_backed_qml_access`; [audit counterexamples](#follow-up-audit-scope-corrections) | INC-QML-07; INC-QML-10/17/18 corrections | Partial; bounded receiver/provider/loader lifecycle passes; INC-QML-21/22 have separate evidence below; wider system gaps remain | + +## Native ownership correction and remaining gaps + +The baseline follow-up audit A13 failed REQ-QML-008-AC01/AC03 and REQ-QML-016-AC01/AC04 +for lexical aliases despite the earlier bounded passes in the individual table. +These statuses are partial/failed for the new cases. INC-QML-19 owns their +registration/emission/connection correction; INC-QML-11 remains inherited lookup. + +INC-QML-10's bounded source-ownership correction is locally verified through the +production facade, graph assembly, JSON publication/reload, aggregate HTML and +real CLI upgrade/retention paths. The final focused ownership/context/upgrade +selection passes 88 cases in 7.38 seconds. The reviewed final-wheel Qt/QML, C++, +HTML and export selection passes 954 cases with seven documented skips. Exact +commands, artifact identity and red/green evidence belong to +[validation](../docs/qt-qml/VALIDATION.md#inc-qml-10-native-source-ownership). +These results do not establish browser appearance or new hosted/platform evidence. + +Accepted unchanged-header contexts have separate production regressions in +`tests/test_qt_cpp_context_ownership.py::test_req_qml008_ac02_borrowed_complete_header_is_not_counted_as_two_definitions` +and `tests/test_qt_cpp_context_ownership.py::test_req_qml016_ac01_context_pipeline_preserves_owned_emission_through_json_reload`. +They map REQ-QML-008-AC02 and REQ-QML-016-AC01/AC04 to actual collector and +pipeline/join/build/publication/reload ownership, canonical IDs and unchanged +borrowed dictionaries. Genuine distinct-body and false/missing completeness +controls in the same module retain unproved endpoints; equivalent relative, +Windows-separator and absolute path spellings retain one body identity. These +cases first failed before the borrowed source/span transport correction and pass +within the final focused selection. + +INC-QML-11 now corrects grandparent inherited-signal endpoint lookup under +REQ-QML-016-AC01/AC04 with the exact source/update evidence below. Earlier +immediate-base-only evidence is superseded for this bounded lookup profile. INC-QML-12/13 now have bounded source +implementation and regression evidence below. Missing/unaccepted class bodies, +collapsed overloads and dynamic targets retain explicit unavailable ownership. +A proven enclosing callable or accepted source file may contain the occurrence +without establishing its native class, QObject role or semantic target. These +corrections do not use containment as proof of a missing class or runtime call. + +## Constructor and source-containment corrections + +INC-QML-12 owns generic constructor declaration/definition proof and canonical +parent correction. INC-QML-13 owns native source-site/file containment, upgrade +invalidation and truthful community counts. Final reviewed policy-5/schema-7 +wheel identity, complete suite and installed public source-file proof are in +[validation](../docs/qt-qml/VALIDATION.md#inc-qml-1213-current-source-and-view-evidence). +The earlier 1029-pass artifact used policy 4 and does not verify the final +source-file change. Each row retains its original criterion identity. + +| Affected criterion | Exact production regression evidence | Boundary and current state | +| --- | --- | --- | +| REQ-QML-008-AC02 | `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_constructor_prototypes_are_callable_methods`; `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_constructor_definition_retains_id_and_exact_accepted_owner`; `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_constructor_join_rejects_foreign_or_corrupt_accepted_proof`; `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_corrupted_owner_cannot_relabel_another_actual_constructor`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_plain_member_uses_exact_callable_without_native_class_claim`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_local_class_links_to_enclosing_callable_without_type_authority`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_source_fallback_rejects_unproved_or_noncallable_context`; `tests/test_qt_source_file_containment.py::test_req_qml008_ac02_unknown_native_owner_keeps_actual_file_context_after_reload`; `tests/test_qt_source_file_containment.py::test_req_qml008_ac02_file_context_requires_unique_actual_file_role` | Locally verified bounded ownership; qualified ID correction has separate INC-QML-21 evidence below | +| REQ-QML-010-AC02/AC04 | `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_generic_constructor_facts_keep_original_bom_crlf_unicode_spans`; `tests/test_qt_constructor_ownership.py::test_req_qml008_ac02_constructor_spans_use_original_bom_crlf_unicode_bytes`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_source_link_survives_build_json_and_aggregate`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_direct_collector_borrows_exact_callable_without_mutating_context`; file containment/reload and role-rejection cases above | Original bytes, canonical IDs, EXTRACTED containment direction, immutable borrowed dictionaries and unresolved native status remain separate from inferred target resolution. Current artifact/reload proof passes. | +| REQ-QML-011-AC03 | `tests/test_qt_source_links_upgrade.py::test_req_qml011_ac03_source_links_upgrade_reparses_unchanged_cpp` (extract/update; prior policy/schema 3/6 and 4/7) | Real CLI reparses unchanged accepted C++ at the same package version, retires incompatible AST entries, preserves unrelated Python and matches a clean rebuild; repeated operation is idempotent. Current policy 5/schema 7 source/upgrade and installed public-fixture evidence pass. | +| REQ-QML-011-AC04 | `tests/test_qt_source_links_upgrade.py::test_req_qml011_ac04_removed_admission_removes_source_overlay`; `tests/test_qt_source_file_containment.py::test_req_qml011_ac04_unowned_file_sites_refresh_and_remove_stale_links`; `tests/test_qt_constructor_ownership.py::test_req_qml016_ac04_and_qml017_ac04_constructor_updates_remove_stale_sites_and_preserve_failures` | Actual cold/warm, manual update and watch, edit/removal, no-change repeat and unrelated facts. Supported constructor/member cases require full normalized parity; exact generic overload identity remains unproved and is not passed by native source-file comparison. | +| REQ-QML-012-AC02 | `tests/test_qt_source_links_upgrade.py::test_req_qml012_ac02_failed_source_links_upgrade_retains_products` (force off/on); constructor mutation/retention case above (manual/watch) | Real malformed-source rejection retains graph, manifest, analysis stamp and applicable root marker; corrected retry and repeat succeed. These cases supplement earlier actual cache/publication-failure regressions, rather than proving every persistence failure by one fixture. Source and current installed public-fixture retention pass. | +| REQ-QML-016-AC01/AC04 | `tests/test_qt_constructor_ownership.py::test_req_qml016_ac01_and_qml017_ac02_constructor_owns_emission_and_write_after_json_reload` (namespace/plain; directed/undirected); `tests/test_qt_constructor_ownership.py::test_req_qml016_ac04_constructor_conflicts_cannot_invent_a_canonical_owner`; constructor mutation/retention case above | Canonical constructor/source ownership through assembly, JSON reload, query and affected; no fabricated delivery call. Duplicate bodies, foreign namespaces and collapsed overloaded delegation retain unavailable native target proof. Inherited endpoint gap INC-QML-11 remains unverified. | +| REQ-QML-017-AC02/AC04 | The same exact constructor emission/write, conflict and mutation/retention tests | Literal accepted resource handles independently authorize QML access in a proven source callable; native class authority is not guessed from that access. Current reviewed source/wheel/public-fixture proof passes; unsupported dynamic handles/provider adoption remain separate gaps. | +| REQ-QML-019-AC02 | [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_internal_only_group_has_source_edges_despite_zero_neighbors`); [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_external_source_edges_are_distinct_from_neighbor_count`); [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_isolated_source_member_does_not_claim_internal_connectivity`); [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_source_edge_counts_keep_direction_parallel_edges_and_self_loops`); [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_invalid_preaggregated_source_counts_remain_unavailable`); supplied-meta/small-view controls in that module | Historical complete-fork evidence; deferred for this submission. Eleven emitted-script/exporter cases pass. Counts use canonical graph edges, preserve source inputs and do not add fake plotted loops; unavailable counts, escaping and ordinary small-view Degree remain explicit. No new browser visual run is claimed. | + +The source-site suite first failed seven cases before correction. The actual +facade source-file case then failed until the pipeline supplied explicit fresh +AST IDs; absent origin on borrowed nodes remains insufficient. The current +focused containment selection passes 63 cases in 9.56 seconds. Final six-module +source proof passes 90 cases in 14.70 seconds; final source-file/upgrade proof +passes 20 cases in 6.06 seconds. The final reviewed artifact broad suite passes +1044 cases with seven skips and one existing warning in 137.43 seconds. All 154 +Python payloads match reviewed source, wheel and installation. The installed +public fixture rejects damaged source with force/partial options while preserving +four prior products, then repairs and repeats successfully. The HTML link +suite first failed six cases, then passes eleven; its related exporter/CLI +selection passes 172 cases. Commands and revision boundaries are in validation. + +## Planned adoption criteria + +Owner: Qt/QML integration maintainer. The three work packages are implemented; +their individual local source and installed acceptance is recorded below. This +heading retains its earlier external anchor. AC07 is a separate header discovery +contract; it does not borrow verification from the native syntax criterion. +The [INC-QML-08 plan](../docs/qt-qml/PLAN.md#inc-qml-08--installed-project-adoption-hardening) +records scope and exit checks. Original REQ-QML-001–017 hosted evidence applies to +its original bounded profile only. Every row here is locally verified for Windows +x64/Python 3.12; current hosted lanes remain unexecuted. + +| Criterion | Evidence or exact remaining gap | Completion increment | Status | +| --- | --- | --- | --- | +| REQ-QML-018-AC01 | [Project/header adoption evidence](#project-and-header-adoption); [final combined installed profile](#final-adoption-delivery) | INC-QML-08a | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified | +| REQ-QML-018-AC02 | [Native syntax and cache upgrade evidence](../docs/qt-qml/VALIDATION.md#inc-qml-08a-native-source-compatibility); [reference-callable evidence](#declared-providers-and-reference-callables); final installed matrix below | INC-QML-08a/29 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified | +| REQ-QML-018-AC03 | [Declared provider and reference-callable evidence](#declared-providers-and-reference-callables); final installed matrix below | INC-QML-08b/29 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified | +| REQ-QML-018-AC04 | [Declared provider and reference-callable evidence](#declared-providers-and-reference-callables); logical direction and mechanism/callback integrity regressions; final installed matrix below | INC-QML-08b/30/36/37 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified | +| REQ-QML-018-AC05 | `tests/test_qt_combined_adoption.py::test_req_qml018_ac05_combined_configured_project_matches_cold_warm_and_updates`; four actual isolated installed CLI profiles and selected installed lifecycle suite; final matrix below | INC-QML-08c/32/38 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified | +| REQ-QML-018-AC06 | `tests/test_qt_combined_adoption_safety.py::test_req_qml018_ac06_combined_rejection_retains_cache_products_and_retries`; `tests/test_qt_combined_adoption_safety.py::test_req_qml018_ac06_combined_readonly_destination_preserves_then_retries`; direction/repeat/publication regressions mapped below; final installed matrix | INC-QML-08c/31–38 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified | + +## HTML community-view criteria + +REQ-QML-019-AC01–AC03 retain local verification at the Python exporter/CLI and +emitted JavaScript boundaries. INC-QML-16 changes AC04 by removing Overview; its +current control acceptance is locally verified at emitted-script and reviewed +installed-artifact boundaries. Native browser/device behavior remains unverified. +Node harnesses execute production scripts with the external +vis network isolated; they prove dataset admission, controls and source payload, +not a new browser-engine or visual acceptance run. Installed-artifact identity and +executed commands are recorded in VALIDATION.md. + +| Criterion | Exact production evidence | Increment | Status | +| --- | --- | --- | --- | +| REQ-QML-019-AC01 | [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac01_large_export_recovers_complete_partition`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac01_invalid_saved_membership_is_rebuilt_without_stale_names`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac01_cli_exports_unclustered_saved_graph_without_sidecars`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac01_explicit_graph_uses_adjacent_analysis_not_malformed_cwd_sidecar`) | INC-QML-09 | Historical complete-fork evidence; deferred for this submission. Locally verified | +| REQ-QML-019-AC02 | [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac02_existing_groups_get_missing_labels`); small/empty/authoritative group and name cases in the same module; `tests/test_qt_graph_html_payload.py::test_qml013_ac03_aggregated_html_explicitly_states_source_fact_omission`; existing escaping and written-node-info runtime regressions in `tests/test_export.py`; [current exact community-count assignments](#constructor-and-source-containment-corrections) | INC-QML-09; INC-QML-13 correction | Historical complete-fork evidence; deferred for this submission. Locally verified exporter/emitted-script counts and reviewed installed artifact; browser visual inspection is separate | +| REQ-QML-019-AC03 | [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac03_invalid_computed_partition_preserves_output_and_recovers`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac03_cli_unavailable_view_retains_prior_outputs_and_retries`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac03_cli_failure_has_no_false_write_and_preserves_prior_html`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac03_isolate_partition_over_hard_cap_is_not_published`) | INC-QML-09 | Historical complete-fork evidence; deferred for this submission. Locally verified; actual atomic replacement failure injected at its OS boundary | +| REQ-QML-019-AC04 | [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_default_select_all_constructs_every_exported_node_and_edge_before_network`); [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_req_qml019_ac04_filters_all_none_preserve_endpoint_safe_source_data`); [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_req_qml019_ac04_search_restores_filtered_source_and_exact_metadata`); [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_small_grouped_and_ungrouped_views_default_to_select_all`); [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_req_qml019_ac04_partial_membership_cannot_mark_hidden_ungrouped_fact_selected`) | INC-QML-09; INC-QML-16 removal | Historical complete-fork evidence; deferred for this submission. Locally verified current emitted-script controls and reviewed installed-artifact scripts. Native browser/device behavior unverified; earlier Overview results are revision-specific and superseded | + + + +## Project-membership projection + +Owner: Qt project-metadata integration maintainer. INC-QML-14 owns all three +REQ-QML-020 criteria. Status: **Locally Verified for the bounded static public +profile**. The new production tests below have executed source, lifecycle, +consumer and reviewed installed-artifact evidence. +Current source/resource lookup, source-file containment and community edge counts +remain evidence for their existing boundary. Each independent membership site +must preserve declaration/target identity, direction, provenance and uncertainty +through the actual graph/consumer lifecycle. Qt policy 6/schema 7 at package +version 0.9.74 has source upgrade and reviewed installed public-fixture evidence. +Additional accepted-code-scope adoption checks pass; whole-root/provider/runtime +adoption gaps remain outside this closure. + +| Criterion | Exact executed production evidence | Completion increment | Status | +| --- | --- | --- | --- | +| REQ-QML-020-AC01 | `tests/test_qt_project_membership.py::test_req_qml020_ac01_unused_component_has_persisted_build_and_resource_membership` (CMake/qmake; directed/undirected); `tests/test_qt_project_membership.py::test_req_qml020_ac01_metadata_spans_survive_bom_crlf_unicode_and_sanitation`; `tests/test_qt_project_membership.py::test_req_qml020_ac01_scoped_paths_repeated_declarations_and_query_keep_exact_identity`; `tests/test_qt_project_membership_updates.py::test_req_qml020_ac01_unused_packaged_component_survives_aggregate_export`. Targets uniquely accepted file/component endpoints, independent/repeated sites and same-name scoped files, `EXTRACTED` references, direction and original spans through facade/build/JSON reload/scoped query, unchanged loader/module lookup and actual aggregate-script membership counts. Source, consumer and final reviewed installed public-fixture evidence pass. | INC-QML-14 | Locally Verified (bounded static public profile) | +| REQ-QML-020-AC02 | `tests/test_qt_project_membership.py::test_req_qml020_ac02_source_projection_rejects_unproved_membership`; `tests/test_qt_project_membership.py::test_req_qml020_ac02_resources_reuse_existing_alias_guard_decisions`; `tests/test_qt_project_membership.py::test_req_qml020_ac02_corrupt_literal_transport_is_rejected`; `tests/test_qt_project_membership.py::test_req_qml020_ac02_location_prefix_cannot_authorize_another_source_span`; `tests/test_qt_project_membership.py::test_req_qml020_ac02_projection_reads_no_targets_and_executes_no_corpus`; `tests/test_qt_project_membership_updates.py::test_req_qml020_ac02_failed_metadata_join_preserves_products_and_recovers` (manual/force/watch); `tests/test_qt_project_membership_updates.py::test_req_qml020_ac02_join_exception_cannot_publish_partial_memberships`. Missing, duplicate, conditional, generated, wrong-role and out-of-root cases must have explicit status/reason and no target edge. The actual helper executes before injected failure; graph/manifest/analysis/root-marker bytes must remain unchanged. Projection guards target reads/discovery/process execution and preserves borrowed facts. All assigned source rejection/retention and final installed public-fixture checks pass. | INC-QML-14 | Locally Verified (bounded static public profile) | +| REQ-QML-020-AC03 | `tests/test_qt_project_membership.py::test_req_qml020_ac03_fresh_derived_sites_replace_borrowed_state_without_mutation`; `tests/test_qt_project_membership.py::test_req_qml020_ac03_typed_file_role_survives_punctuation_and_borrowed_publication`; `tests/test_qt_project_membership_updates.py::test_req_qml020_ac03_metadata_resource_and_source_updates_remove_stale_memberships` (manual/watch); `tests/test_qt_project_membership_updates.py::test_req_qml020_ac03_policy_upgrade_refreshes_unchanged_packaging` (extract/update); `tests/test_qt_project_membership_updates.py::test_req_qml020_ac03_membership_ids_are_independent_of_checkout_location`; `tests/test_qt_project_membership_updates.py::test_req_qml020_ac03_pipeline_publishes_replacement_without_borrowed_mutation`; malformed-resource recovery and aggregate-export tests above. Targets cold/warm/full/manual/watch parity, source edit/rename with metadata/alias updates, alias duplicates, target deletion, stale-edge removal, stable unrelated Python/relocated root identity, accepted typed-file identity through punctuation/publication, fresh-site replacement without borrowed mutation and policy-5 to policy-6 refresh without source/package changes. Complete source/lifecycle/consumer and reviewed installed public-fixture evidence pass for the bounded profile. | INC-QML-14 | Locally Verified (bounded static public profile) | + +The first actual lifecycle selection failed all four cases in 3.76 seconds before +projection existed: the public fixture had no `membership_resolution` sites. +The failure record is baseline regression proof. Exact executed commands, +artifact identity and remaining broader limitations are in +[validation](../docs/qt-qml/VALIDATION.md#inc-qml-14-membership-projection). +The frozen source-projection module passes 26 cases in 1.90 seconds; its positive +facade cases fail twice with only projection disabled and actual parsing/indexes +retained. The reviewed-wheel checkpoint's combined selection passes 37 cases in +8.77 seconds. Two added directed graph variants then pass within a final +39-case focused selection in 9.50 seconds, with no production change. The +reviewed-wheel broad selection passes 1081 cases with seven documented skips and +one existing warning in 135.53 seconds. All 155 Python modules are byte-equal +between reviewed source, wheel and isolated installation. The installed public +CMake/qmake/qrc fixture has six resolved membership sites, accepted consumer/HTML +results, and actual force/partial failure retention followed by repair/repeat. +Browser visual, other-platform and executable Qt proof are not claimed. The +additional accepted-code-scope adoption refresh also passes. That INC-QML-14 +checkpoint contained 81 criteria; the subsequent camera requirement adds three +without changing earlier criterion identities or revision evidence. + +## Temporary middle-button camera and Overview removal + +Owner: HTML viewer maintainer. INC-QML-16 owns REQ-QML-021-AC01–AC03 and the changed +REQ-QML-019-AC04, depending on the existing REQ-QML-019 community-view contract. +Status: **Locally verified at emitted-script and reviewed installed-artifact +boundaries; native browser/device and other-platform behavior unverified**. +The tests below execute the actual +emitted navigation script with a recording camera/network boundary; a stubbed +network does not establish a browser-engine, layout or native-device result. +The initial-view module separately retains checked startup, controls, dataset and +source metadata assertions while removing obsolete Overview expectations. + +| Criterion | Exact production evidence and boundaries | Increment | Status | +| --- | --- | --- | --- | +| REQ-QML-021-AC01 | [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac01_middle_drag_translates_both_axes_at_current_zoom`) (24 horizontal/vertical/diagonal, four-scale, source/aggregate variants); incremental client delta divided by current zoom, preserved scale, no animation, and suppressed native middle autoscroll | INC-QML-16 | Historical complete-fork evidence; deferred for this submission. Locally verified emitted-script and reviewed installed-artifact boundary; native browser/device and other platforms unverified | +| REQ-QML-021-AC02 | [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_release_cancel_blur_and_lost_buttons_end_drag`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_capture_failure_has_window_fallback_and_no_lingering_drag`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_invalid_camera_or_input_aborts_without_jump`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_invalid_press_cannot_capture_or_resume_after_repair`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_unrelated_pointer_and_nonmiddle_release_preserve_owned_drag`). Matching release/cancel, lost button/capture, blur/pagehide, outside movement/capture fallback, invalid finite/scale/derived-position and cursor restoration controls prove termination and no later jump | INC-QML-16 | Historical complete-fork evidence; deferred for this submission. Locally verified emitted-script and reviewed installed-artifact boundary; native browser/device and other platforms unverified | +| REQ-QML-021-AC03 | [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac03_other_inputs_and_source_datasets_remain_unchanged`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac03_filters_and_search_work_after_middle_drag`); left/right/touch/wheel coexistence and actual startup/filter/all/none/search/inspector controls preserve graph/payload/dataset metadata, node positions, physics and temporary camera-only state | INC-QML-16 | Historical complete-fork evidence; deferred for this submission. Locally verified emitted-script and reviewed installed-artifact boundary; native browser/device and other platforms unverified | + +The focused command executes both current modules: + +Historical invocation/source excerpt: [frozen TRACEABILITY lines 1033–1036](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/TRACEABILITY.md#L1033-L1036). Deferred owners: [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py), [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py). + +Result: **60 passed**, 6.57 seconds, with no skips, after restoring retained Qt +projection, metadata/span/attribute and endpoint-safe source-edge assertions. +The earlier 60-pass/6.71-second checkpoint precedes that assertion review; the +44-pass/4.99-second checkpoint precedes independent horizontal/vertical/diagonal expansion. +An earlier missing-hook selection has 28 failed cases; the integration owner's validation record owns its +exact command/revision. Earlier exporter/CLI/membership-consumer regression proof +passes 219 cases in 54.20 seconds before the sixteen additional geometry cases. +The final exporter/CLI/membership/inspector selection passes 231 cases in 53.15 +seconds, without failures or skips. +Wheel-artifact/Qt HTML consumers pass seven cases in 4.60 seconds, and the legacy +export module passes 67 cases in 2.95 seconds. These recorded script/artifact +boundaries do not close native browser/device behavior. The final 231-case +selection and seven additional consumer/wheel cases cover 238 distinct cases. +Five touched owners pass Ruff; the four navigation/source/test owners pass +explicit-runtime Pyright with zero errors/warnings. Including the legacy +`tests/test_export.py` owner reports one unchanged `reportOptionalOperand` error +at line 1008, reproduced against the prior revision with the same explicit +include/exclude configuration. The narrow listener-registration adjustment does +not alter that optional-return expression, inspector assertions or type checks. + +The reviewed production tree is `a991dd9fb2d638cf15fbb2c76499ed1f6ebd51ea`; wheel +SHA256 is `11e370a19d328a01b4e9e30726731c815b4ef58399833ff3d0a7c644f71e98f0`. +All 156 Python payloads match reviewed source, wheel and isolated installation. +The installed viewer artifact retains canonical/RAW data, checked startup and +source inspectors; actual emitted-script camera moves and release/blur/retry +cleanup pass. No private input identifiers, paths or source are retained here. +Helper/HTML/initial-view/middle-pan measurements are 116/789/245/223 physical lines; +the updated inspector harness remains within its documented 1377-line exception. +Qt policy 6 and AST schema 7 are unchanged; this is viewer input, with no source +execution, new SDK, persistence operation or graph diagnostic. Earlier Overview +and camera selection evidence is historical. A changed requirement is verified +only after its own applicable controls and failure/cleanup cases pass; broader +adoption, inherited endpoint and constructor-overload gaps remain independent. + +## Follow-up audit scope corrections + +Inspected production revision: `95adbdc165f44a96bf275a7870bb1da4d82a5bea`. +Public static fixtures; Python 3.12.14, tree-sitter 0.25.2 and language-pack 0.11.0; +Windows. No Qt SDK or corpus execution. The probes are explicit opt-in diagnostic +tests and retain correct failing assertions. Their default-discovery exclusion is +temporary audit ownership, not regression completion. The correction owner must +promote them into normal collection and reverify all affected criteria. + +| Acceptance | Exact probe or system assignment | Outcome / correction owner | +| --- | --- | --- | +| REQ-QML-017-AC02/AC04 | `tests/audit/probe_qt_qml_object_boundaries.py::test_cpp_reflective_child_property_does_not_inherit_qml_lexical_root` (read/write/invoke variants) | Three persisted-edge rejections fail; INC-QML-17 reverse-access resolver owner | +| REQ-QML-017-AC02/AC04 | `tests/audit/probe_qt_qml_object_boundaries.py::test_cpp_findchild_does_not_search_sibling_outside_receiver_subtree`; `tests/audit/probe_qt_qml_object_boundaries.py::test_cpp_findchild_direct_search_cannot_select_grandchild` | Both persisted-edge rejections fail; INC-QML-17 | +| REQ-QML-017-AC03/AC04 | `tests/audit/probe_qt_qml_object_boundaries.py::test_disjoint_engine_scopes_cannot_supply_context_provider` | Provider edge reaches another engine; rejection fails; INC-QML-18 context integration owner | +| REQ-QML-017-AC02/AC03 | `tests/audit/probe_qt_qml_object_boundaries.py::test_public_receiver_and_engine_positive_controls` | Own-object/root/engine controls pass; they do not validate failing scopes | +| REQ-QML-018-AC03 | `tests/audit/probe_qt_qml_object_boundaries.py::test_pending_typed_factory_provider_remains_unresolved` | Historical baseline exclusion only at 95adbdc; superseded by ordinary positive typed-provider regressions in INC-QML-08b. This opt-in probe is not current acceptance | +| REQ-QML-016-AC02/AC03/AC04 | `tests/audit/probe_qt_native_type_shadowing.py::test_req_qml016_connect_alias_cannot_select_unrelated_global_signal` (using/typedef); `tests/audit/probe_qt_native_type_shadowing.py::test_req_qml016_block_alias_does_not_change_before_and_after_native_scope` | Three wrong persisted-endpoint cases fail; INC-QML-19 native type/endpoint owners | +| REQ-QML-016-AC01/AC03/AC04 | `tests/audit/probe_qt_native_type_shadowing.py::test_req_qml016_emission_alias_cannot_select_unrelated_global_signal` (using/typedef) | Two wrong emission endpoints fail; INC-QML-19 | +| REQ-QML-008-AC01/AC03 | `tests/audit/probe_qt_native_type_shadowing.py::test_req_qml008_alias_registration_cannot_export_global_class_to_qml` (using/typedef) | Two wrong native-to-QML registration/handler cases fail; INC-QML-19 | +| REQ-QML-008-AC01 and REQ-QML-016-AC01/AC02/AC04 | `tests/audit/probe_qt_native_type_shadowing.py::test_direct_native_type_control_preserves_roles_and_direction`; `tests/audit/probe_qt_native_type_shadowing.py::test_direct_registration_control_preserves_native_qml_endpoint` (Sender/Other variants) | Four direct native/QML controls pass through actual publication and directed reload | +| REQ-QML-021-AC01–AC03 and REQ-QML-019-AC04 | [Native navigation review procedure](../docs/qt-qml/VIEWER_SYSTEM_REVIEW.md) | Historical complete-fork evidence; deferred for this submission. Exact fixture/actions/evidence/failure/cleanup/owner defined; physical browser/device/platform execution remains unverified | +| REQ-QML-017-AC01/AC04 | `tests/audit/probe_qt_loader_forms.py::test_req_qml017_ac01_literal_loader_keeps_component_and_property_provenance` (engine URL constructor/component loadUrl/engine.load variants) | Two loader-admission assertions fail; engine.load control passes; INC-QML-20 collector/integration owner | + +Executed command: + +```text +.venv/Scripts/python.exe -X utf8 -m pytest tests/audit/probe_qt_qml_object_boundaries.py tests/audit/probe_qt_native_type_shadowing.py tests/audit/probe_qt_loader_forms.py -q --tb=short +``` + +Final combined result: **15 failed, 7 passed in 3.38 seconds**, one existing Hypothesis collection +warning. Thirteen failing variants expose four wrong-target root causes; two more expose loader omissions. +The independent existing lifecycle selection passed **67 tests in 21.43 seconds**; +the final native/QML compatibility selection passed **60 tests in 4.85 seconds**. +Neither establishes cold/warm/manual/watch parity for a correction not implemented. +At that baseline audit, reviewed installed-wheel, complete repository suite and +new hosted proof were unexecuted for the corrections. Current per-increment +package proof and repository gate failures are recorded in validation; new hosted +proof remains unexecuted. See [audit](../docs/qt-qml/FOLLOWUP_AUDIT.md) +and [plan](../docs/qt-qml/PLAN.md) for scope, dependencies and delivery gates. + +## Receiver correction evidence (INC-QML-17) + +These current mappings supplement the historical audit/profile rows above. +The reviewed installed artifact passes 98 selected cases with no skips/failures; +exact wheel identity and dependency limits are in +[validation](../docs/qt-qml/VALIDATION.md#inc-qml-17-receiver-ownership-and-construction-trees). +Full contribution gates ran at INC-QML-20; documented baseline failures remain. + +| Acceptance | Automatically collected production evidence | Current boundary | +| --- | --- | --- | +| REQ-QML-017-AC02 | `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_child_reflection_cannot_use_lexical_root`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_findchild_rejects_siblings_depth_and_receiver`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_property_held_qobject_has_construction_owner`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac04_native_base_members_and_property_child_keep_source_proof`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_inherited_member_is_receiver_owned_and_readonly_write_rejected` | Partial; receiver correction passes; static reflection correction has separate INC-QML-22 evidence below | +| REQ-QML-017-AC02/AC04 | `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_duplicate_names_are_ambiguous_only_inside_receiver`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_parent_bindings_and_js_writes_do_not_authorize_construction_tree`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_unknown_types_and_component_templates_remain_unavailable`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_construction_lookup_requires_accepted_declaration_proof`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_proved_cpp_parent_mutation_keeps_source_but_no_target`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_unsupported_findchild_options_cannot_choose_target`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_unproved_findchild_type_filter_cannot_choose_object`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_noncreatable_singleton_and_gadget_cannot_prove_qobject_creation` | Rejection/ambiguity and accepted source-construction proof pass without guessed targets | +| REQ-QML-017-AC04 | `tests/test_qt_semantic_correction_updates.py::test_req_qml017_ac04_receiver_tree_and_member_updates_remove_stale_edges`; `tests/test_qt_semantic_correction_updates.py::test_req_qml017_ac04_policy_refresh_failure_retains_products_and_retry`; `tests/test_qt_semantic_correction_updates.py::test_req_qml017_ac04_failed_completion_keeps_prior_graph_and_retries` | Partial; bounded receiver/provider/loader lifecycle passes; INC-QML-21/22 have separate evidence below; wider system gaps remain | + +Existing QML scope/declaration/identity, loader, provider and reverse-update tests +also pass against that reviewed wheel. Factory providers, inherited native signal +ancestors and generic overload identity retain their separately recorded gaps. + +## Declaration identity correction evidence (INC-QML-18) + +These ordinary collected regressions replace A12's opt-in reproduction as current +acceptance evidence. Native/system lifetime and broader provider adoption remain +explicit gaps; the source profile does not execute a Qt engine. + +| Acceptance | Exact production-boundary test | Observable evidence | +| --- | --- | --- | +| REQ-QML-017-AC03; REQ-QML-008-AC01 | `tests/test_qt_engine_identity.py::test_req_qml017_ac03_disjoint_same_named_engines_cannot_share_provider`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_same_engine_positive_retains_persisted_provider`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_nested_engine_shadow_does_not_receive_outer_provider`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_local_provider_lifetime_cannot_supply_later_load`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_engine_and_provider_parameters_have_exact_identity` | Disjoint/nested engines and expired providers have no persisted binding; exact local/parameter positive controls retain component and native property endpoints | +| REQ-QML-017-AC03/AC04 | `tests/test_qt_engine_identity.py::test_req_qml017_ac03_rejected_identity_preserves_precise_source_diagnostic`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_initial_properties_report_only_shared_identity_failure`; `tests/test_qt_engine_identity.py::test_req_qml017_ac04_writes_and_duplicate_declarations_fail_closed`; `tests/test_qt_engine_identity.py::test_req_qml017_ac04_conditional_and_deferred_lifetimes_have_no_identity`; `tests/test_qt_engine_identity.py::test_req_qml017_ac04_missing_or_corrupted_transport_cannot_fall_back_to_name` | Source-owned rejection reasons, mixed initial properties and corrupted transport never authorize a name-based fallback | +| REQ-QML-017-AC04 | `tests/test_qt_engine_identity.py::test_req_qml017_ac04_auto_and_explicit_handles_keep_declaration_identity`; `tests/test_qt_engine_identity.py::test_req_qml017_ac04_ids_are_portable_with_original_unicode_bom_crlf_spans`; `tests/test_qt_engine_identity_updates.py::test_req_qml017_ac04_engine_scope_changes_remove_stale_provider_links`; `tests/test_qt_engine_identity_updates.py::test_req_qml017_ac04_malformed_identity_source_retains_products_and_retries`; `tests/test_qt_engine_identity_updates.py::test_req_qml017_ac04_directed_query_affected_and_reload_keep_scoped_provider` | Partial; bounded receiver/provider/loader lifecycle passes; INC-QML-21/22 have separate evidence below; wider system gaps remain | + +Exact source/artifact commands and outcomes belong to the corresponding validation +section. Full contribution gates run at the INC-QML-20 integration boundary. + +## Native alias and ancestry correction evidence (INC-QML-19) + +Ordinary collected cases supersede A13's opt-in probe as current bounded source +evidence. Imported-header targets, inherited event endpoints and generic overload +identity remain explicitly excluded or assigned to INC-QML-11/15. + +| Acceptance | Exact test | Evidence | +| --- | --- | --- | +| REQ-QML-016-AC01/AC02/AC03/AC04 | `tests/test_qt_native_alias_scope.py::test_req_qml016_connect_alias_cannot_select_unrelated_global_signal`; `tests/test_qt_native_alias_scope.py::test_req_qml016_emission_alias_cannot_select_unrelated_global_signal`; `tests/test_qt_native_alias_scope.py::test_req_qml016_block_alias_does_not_change_before_and_after_native_scope`; `tests/test_qt_native_alias_scope.py::test_direct_native_type_control_preserves_roles_and_direction`; `tests/test_qt_native_alias_scope.py::test_namespace_lookup_and_namespace_alias_keep_original_byte_spans` | Source-local alias rejection/positive controls preserve canonical roles, relation distinctions and original spans | +| REQ-QML-008-AC01/AC03 | `tests/test_qt_native_alias_scope.py::test_req_qml008_alias_registration_cannot_export_global_class_to_qml`; `tests/test_qt_native_alias_scope.py::test_direct_registration_control_preserves_native_qml_endpoint`; `tests/test_qt_native_alias_scope.py::test_proven_alias_registration_exports_the_actual_class_and_qml_signal`; `tests/test_qt_native_alias_scope.py::test_local_alias_positive_and_rejection_profile` | Registration/QML projections select accepted aliases or retain no guessed provider | +| REQ-QML-016-AC01–AC04; REQ-QML-008-AC01/AC03 | `tests/test_qt_native_alias_updates.py::test_alias_type_edit_and_removal_refreshes_native_qml_cold_warm_consumers`; `tests/test_qt_native_alias_updates.py::test_unprovable_alias_replaces_old_edges_without_global_name_fallback`; `tests/test_qt_native_alias_updates.py::test_bad_native_alias_input_retains_outputs_then_recovers_and_repeats`; `tests/test_qt_native_alias_updates.py::test_accepted_header_alias_cannot_resolve_outer_global_native_or_qml_endpoint`; `tests/test_qt_native_alias_updates.py::test_transitive_header_alias_owns_shadow_but_not_prior_source_use`; `tests/test_qt_native_alias_updates.py::test_included_alias_fact_corruption_rejects_provenance_instead_of_resolving_outer_type`; `tests/test_qt_native_alias_updates.py::test_fresh_alias_header_uses_borrowed_qt_context_without_mutating_it` | Real manual/watch full/cold/warm, stale-edge removal, persisted/query/affected, parse/write failure retention and retry; included alias provenance rejection and borrowed-context isolation | +| REQ-QML-017-AC02/AC04 | `tests/test_qt_construction_authority.py::test_req_qml017_ac02_widget_and_unknown_ancestry_cannot_authorize_child_tree`; `tests/test_qt_construction_authority.py::test_req_qml017_ac04_direct_and_source_defined_qobject_chain_survives_publication`; `tests/test_qt_construction_authority.py::test_req_qml017_ac02_native_ancestry_reads_only_accepted_complete_source_facts`; `tests/test_qt_construction_authority.py::test_req_qml017_ac04_parent_mutation_spans_are_bounded_original_bytes`; `tests/test_qt_reflection_type_aliases.py::test_req_qml017_ac02_findchild_filter_rejects_shadowed_sdk_type` | Widget/unknown/corrupt/native shadow rejection; direct/derived non-widget controls, original 50/51 mutation-span boundary and unshadowed QObject filter | + +Exact executed source/artifact evidence is retained in validation; whole-requirement +verification remains bounded by recorded adoption/inheritance/overload/system gaps. + +At the INC-QML-19 checkpoint same-file qualified canonical classes remained an +implementation gap under REQ-QML-008-AC02, REQ-QML-016-AC01/AC04 and +REQ-QML-017-AC02/AC04. The original negative control proved safe rejection. +INC-QML-21 adds independent producer identities and collected proof/update cases +below; the genuine same-file native-versus-widget assertion now proves exact +ownership without weakening the unrelated rejection controls. + +## Literal loader correction evidence (INC-QML-20) + +| Acceptance | Exact ordinary test | Evidence | +| --- | --- | --- | +| REQ-QML-017-AC01/AC04 | `tests/test_qt_loader_provenance.py::test_req_qml017_ac01_literal_loader_keeps_component_and_property_provenance` | Existing engine.load control, URL engine constructor and component loadUrl/create retain original source spans, component/property targets and persisted direction | +| REQ-QML-017-AC01/AC04 | `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_supported_loader_overloads_have_one_source`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_local_file_wrapper_cannot_become_resource_url`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_absolute_fromlocalfile_retains_file_component`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_url_wrapper_shadow_cannot_lend_literal_argument`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_custom_component_engine_cannot_authorize_constructor`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_global_sdk_constructor_bypasses_namespace_shadow`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_sdk_name_shadow_cannot_authorize_loader`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac04_unknown_overload_or_creation_cannot_prove_root`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_constructor_mode_needs_sdk_type_authority`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_string_wrapper_requires_source_authority` | Literal controls, parent/mode/context boundaries, absolute file vs resource/relative/computed URLs and source-defined class/alias/callable rejection | +| REQ-QML-017-AC03/AC04 | `tests/test_qt_loader_providers.py::test_req_qml017_ac03_component_load_url_uses_its_exact_engine_provider`; `tests/test_qt_loader_providers.py::test_req_qml017_ac03_component_engine_shadow_cannot_borrow_another_provider`; `tests/test_qt_loader_providers.py::test_req_qml017_ac03_component_engine_assignment_condition_and_factory_reject` | Named/context-object provider joins use exact component-owned engine identity; shadows/reassignment/conditional/factory results cannot lend provider authority | +| REQ-QML-017-AC04 | `tests/test_qt_loader_updates.py::test_req_qml017_ac04_loader_edits_match_cold_warm_manual_and_watch`; `tests/test_qt_loader_updates.py::test_req_qml017_ac04_loader_parse_failure_preserves_products_and_recovers`; `tests/test_qt_loader_updates.py::test_req_qml017_ac04_loader_write_failure_preserves_products_and_retries`; `tests/test_qt_loader_updates.py::test_req_qml017_ac04_loader_directed_reload_query_and_affected_keep_endpoints` | Real source/resource/member edits and removal, cold/warm/manual/watch parity, stale-edge removal, four-product parse/replace failure retention, repaired retry/idempotency and actual persisted query/affected endpoints | +| REQ-QML-001-AC01/AC02; REQ-QML-003-AC01 | `tests/test_languages.py::test_qml_language_facade_preserves_source_declarations_and_spans` | CONTRIBUTING language convention, production facade and hand-checked declaration/original-byte spans; optional-parser absence is explicit skip, not acceptance | + +The INC-QML-20 checkpoint retained failing opt-in INC-QML-22 cases under REQ-QML-017-AC02/AC04: +`tests/audit/probe_qt_reflection_type_shadowing.py::test_req_qml017_ac02_shadowed_reflection_type_cannot_create_qml_target` +and its positive control +`tests/audit/probe_qt_reflection_type_shadowing.py::test_req_qml017_ac04_unshadowed_sdk_reflection_preserves_targets_and_provenance`. +At the audited baseline twelve failures and two passes established the gap. +The opt-in probe remains excluded historical evidence; the ordinary INC-QML-22 +regressions below now exercise the corrected rejection and positive controls. + +The INC-QML-20 checkpoint retained the actual Windows failure assigned to INC-QML-23: +`tests/test_atomic_writes.py::test_write_text_atomic_refuses_a_readonly_destination_without_leaking_a_temp` +under REQ-QML-018-AC06. It failed on the original baseline and INC-QML-20 +checkpoint. INC-QML-23/25 ordinary tests below cover the corrected actual OS +retention and caller completion boundary; wider adoption remains unverified. + +## Follow-up correction evidence (INC-QML-21–27) + +These cases extend the bounded profile; they do not close all wider adoption, +inherited-endpoint or overload criteria. Exact final commands, installed artifact +identity and contribution gate limitations are recorded in validation. + +| Acceptance IDs | Ordinary production-boundary tests | Evidence scope | +| --- | --- | --- | +| REQ-QML-008-AC02; REQ-QML-016-AC01/AC04; REQ-QML-017-AC02/AC04 | `tests/test_cpp_qualified_identity.py::test_req_qml008_ac02_qualified_class_bodies_keep_distinct_portable_identities`; `tests/test_cpp_qualified_proof.py::test_req_qml008_ac02_using_namespace_bound_cannot_discard_competing_owner`; `tests/test_cpp_qualified_updates.py::test_req_qml017_ac04_qualified_owner_edits_match_cold_warm_updates_and_consumers` | Qualified IDs/body proof, signature/cap rejection, original bytes, canonical calls/emissions, full/manual/watch and affected preservation | +| REQ-QML-017-AC02/AC04 | `tests/test_qt_reflection_type_identity.py::test_req_qml017_ac02_shadowed_reflection_type_cannot_create_qml_target`; `tests/test_qt_reflection_type_identity.py::test_req_qml017_ac04_sdk_reflection_query_and_affected_use_exact_persisted_site`; `tests/test_qt_reflection_updates.py::test_req_qml017_ac04_reflection_source_qml_metadata_edits_remove_stale_targets`; `tests/test_qt_reflection_updates.py::test_req_qml017_ac04_reflection_parse_failure_retains_four_products_and_recovers`; `tests/test_qt_reflection_updates.py::test_req_qml017_ac04_reflection_write_failure_retains_four_products_and_retries` | Collected replacement for excluded reflection probe; strict whole-graph lifecycle and real failure/retry | +| REQ-QML-017-AC01/AC04 | `tests/test_qt_loader_sdk_declarations.py::test_req_qml017_ac01_incomplete_sdk_declaration_cannot_authorize_loader`; `tests/test_qt_loader_sdk_declarations.py::test_req_qml017_ac04_uncertain_sdk_use_retains_observed_loader`; `tests/test_qt_loader_sdk_declarations.py::test_req_qml017_ac04_loader_forward_edits_retire_and_restore_targets`; `tests/test_qt_loader_sdk_declarations.py::test_req_qml017_ac04_loader_forward_failure_retains_products_and_recovers` | SDK/source controls through JSON reload, full/manual/watch edits, uncertain facts, stale-edge removal and durable parse/publication recovery | +| REQ-QML-018-AC06; REQ-QML-011-AC04; REQ-QML-013-AC03 | `tests/test_atomic_replace_retention.py::test_req_qml018_ac06_readonly_rejects_without_displacing_destination`; `tests/test_atomic_replace_retention.py::test_req_qml018_ac06_fallback_failure_restores_prior_state`; `tests/test_atomic_replace_retention.py::test_req_qml018_ac06_restore_failure_keeps_recovery_backup`; `tests/test_qt_readonly_publication.py::test_req_qml018_ac06_readonly_graph_preserves_products_then_retries` | Actual Windows OS read-only failure, fallback ordering, retention and guarded caller retry; system/platform gaps remain separate | +| REQ-QML-012-AC02; REQ-QML-018-AC06; REQ-QML-011-AC04 | `tests/test_qt_product_publication.py::test_inc25_readonly_each_product_preserves_cohort_and_retries`; `tests/test_qt_product_publication.py::test_inc25_preparation_and_late_replace_faults_preserve_then_retry`; `tests/test_qt_product_publication.py::test_inc25_first_build_failure_publishes_no_acceptance_products`; `tests/test_publication.py::test_inc25_actual_rollback_fault_retains_recovery_copies`; `tests/test_publication.py::test_inc25_cleanup_failure_does_not_misreport_cohort_authority` | Actual durable acceptance cohort, first-build and later-stage failures, rollback integrity/cleanup and independent valid AST cache boundary | +| REQ-QML-011-AC02/AC04; REQ-QML-017-AC04 | `tests/test_qt_orphan_cleanup.py::test_req_qml017_ac04_watch_restored_sdk_source_has_no_stale_generic_placeholder`; `tests/test_qt_orphan_cleanup.py::test_req_qml011_ac04_complete_refresh_keeps_semantic_connected_and_hyperedge_stub_context`; `tests/test_qt_orphan_cleanup.py::test_req_qml011_ac04_partial_refresh_is_not_authority_to_remove_placeholder` | Strict full graph parity, immutable borrowed state and preserved live/semantic/hyperedge/source ownership | +| REQ-QML-007-AC01/AC03; REQ-QML-008-AC02/AC03; REQ-QML-017-AC03/AC04 | `tests/test_qt_native_property_notify.py::test_req_qml007_ac03_native_property_handler_maps_custom_and_conventional_notify`; `tests/test_qt_native_property_notify.py::test_req_qml008_ac02_shared_notify_preserves_two_independent_handler_sites`; `tests/test_qt_native_property_notify.py::test_req_qml007_ac03_notify_binds_only_block_injection_or_explicit_formals`; `tests/test_qt_native_property_notify.py::test_req_qml017_ac04_native_notify_query_and_affected_use_canonical_signal` | Real canonical signal/provider subscriptions, invalid notify rejection, explicit/legacy binding, reload/query/affected; wider native inheritance remains unverified | + +The [exposure chapter review](../docs/qt-qml/EXPOSURE_CHAPTER_REVIEW.md) names the +official mechanism checklist. Macro accountability is separately recorded in the +API inventory. Excluded audit probes remain historical evidence and cannot by +themselves verify release acceptance. Corrected scenarios use ordinary discovery. + +Native parameter/lifecycle mappings additionally cover: + +| Acceptance IDs | Exact collected test | Scope | +| --- | --- | --- | +| REQ-QML-007-AC01/AC03 | `tests/test_qml_handler_formals.py::test_req_qml007_ac03_source_handler_formals_preserve_property_dependency`; `tests/test_qml_handler_formals.py::test_req_qml007_ac03_handler_authority_survives_parameter_display_limit`; `tests/test_qml_handler_formals.py::test_req_qml007_ac03_signal_parameter_overflow_rejects_without_truncation`; `tests/test_qml_handler_formals.py::test_req_qml007_ac03_handler_change_does_not_reclassify_unrelated_javascript` | Pure-QML/native explicit binding, display bounds and unchanged ordinary JS classification | +| REQ-QML-007-AC03; REQ-QML-008-AC02/AC03; REQ-QML-017-AC03/AC04 | `tests/test_qt_native_notify_updates.py::test_req_qml017_ac04_notify_edits_remove_stale_edges_with_full_parity`; `tests/test_qt_native_notify_updates.py::test_req_qml017_ac04_notify_parse_failure_retains_products_and_recovers`; `tests/test_qt_native_notify_updates.py::test_req_qml017_ac04_notify_manifest_failure_rolls_back_and_retries` | Cold/warm, directed/default, source/QML/qmake edits, manual/force/watch rejection and post-graph sidecar rollback/recovery | + +| Acceptance IDs | Exact collected included-header/setup test | Scope | +| --- | --- | --- | +| REQ-QML-017-AC01/AC04 | `tests/test_qt_loader_header_shadows.py::test_req_qml017_ac01_included_forward_class_blocks_sdk_loader_and_wrapper_authority`; `tests/test_qt_loader_header_shadows.py::test_req_qml017_ac04_header_only_forward_edits_match_cold_warm_and_restore`; `tests/test_qt_loader_header_shadows.py::test_req_qml017_ac04_header_shadow_failure_preserves_products_and_retries`; `tests/test_qt_loader_header_shadows.py::test_req_qml017_ac04_class_shadow_walk_overflow_rejects_instead_of_discarding_header` | Accepted literal include/provenance shadows, independent native target roles, header-only lifecycle, failure/retry and 129-header rejection | +| REQ-QML-012-AC02; REQ-QML-018-AC06 | `tests/test_publication.py::test_inc25_setup_fault_has_safe_code_and_preserves_retry`; `tests/test_publication.py::test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence`; `tests/test_qt_product_publication.py::test_inc25_successful_ast_cache_does_not_authorize_failed_products` | Safe destination/setup diagnostics, owned partial setup cleanup and unchanged original successful cache-entry bytes with valid new entries | + +Final source and installed acceptance for INC-QML-21–27 is recorded in +[validation](../docs/qt-qml/VALIDATION.md#final-local-follow-up-integration-evidence). +All executed correction cases pass. The installed publication symlink case, +existing atomic symlink/mode and external-symlink admission cases remain explicit +host gaps; optional language skips do not establish acceptance. Full repository +pytest and typing retain the separately recorded baseline failures. Broader +adoption/inherited native endpoint/overload criteria remain Partially verified. + + +## Inherited endpoints and pending adoption integration + +| Acceptance ID | Exact production-boundary evidence | State | +| --- | --- | --- | +| REQ-QML-016-AC01 | `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac01_grandparent_endpoints_use_declaring_members`; `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac01_diamond_deduplicates_declarations_and_rejects_conflicts`; `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac01_shadowing_precedes_role_signature_and_visibility`; `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac01_ancestor_traversal_has_a_fail_closed_32_class_budget`; `tests/test_qt_inherited_access.py::test_req_qml016_ac01_external_member_pointer_cannot_cross_nonpublic_base`; missing/corrupt access and lexical namespace controls in those modules | Locally verified inherited declaration/access profile; explicit missing-declaration emissions remain INC-QML-28 | +| REQ-QML-016-AC04 | `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac04_ancestor_edges_reach_reload_query_and_affected`; `tests/test_qt_inherited_updates.py::test_req_qml016_ac04_header_signal_base_and_site_edits_match_full_cold_warm`; `tests/test_qt_inherited_updates.py::test_req_qml016_ac04_malformed_ancestor_preserves_products_then_retries`; `tests/test_qt_inherited_updates.py::test_req_qml016_ac04_readonly_ancestor_refresh_preserves_cohort_and_retry` | Local manual/watch, reload/query/affected and real failure/retry pass; reviewed installed suite passes; wider hosted matrix unverified | +| REQ-QML-018-AC07 | [Project/header adoption evidence](#project-and-header-adoption) | Local source/update pass; reviewed installed suite passes; wider hosted matrix unverified | + +Current adoption criterion count is seven, bringing the catalog to 85 acceptance +criteria across 21 requirements. Earlier six-criterion counts describe the earlier +profile. No criterion is renumbered. Runtime execution, arbitrary include search, +Qt SDK equivalence and other platforms remain outside this bounded static proof. + +## Exact constructor overloads + +| Acceptance IDs | Exact ordinary production tests | State and boundary | +| --- | --- | --- | +| REQ-QML-008-AC02 | `tests/test_cpp_overload_identity.py::test_req_qml008_ac02_overload_producer_ids_spans_and_exact_declaration_merge`; `tests/test_cpp_overload_identity.py::test_req_qml008_ac02_constructor_ids_survive_overload_addition_removal_and_parameter_rename`; `tests/test_cpp_overload_identity.py::test_req_qml008_ac02_distinct_pointer_reference_and_builtin_types_never_collapse`; `tests/test_cpp_overload_type_authority.py::test_req_qml008_ac02_shadow_transport_spans_address_original_bom_crlf_unicode_bytes`; `tests/test_qt_overload_consumers.py::test_req_qml008_ac02_and_qml011_ac01_exact_overload_native_consumers` | Local producer/facade/source/export/query/affected pass; exact accepted signature and original-span profile. Reviewed installed suite passes; wider hosted matrix unverified. | +| REQ-QML-008-AC03 | `tests/test_cpp_overload_identity.py::test_req_qml008_ac03_unsupported_signatures_keep_visible_distinct_occurrences_without_owner`; `tests/test_cpp_overload_identity.py::test_req_qml008_ac03_qualified_void_cannot_authorize_zero_argument_constructor`; `tests/test_cpp_overload_type_authority.py::test_req_qml008_ac03_angle_local_shadow_cannot_grant_sdk_constructor_identity`; `tests/test_cpp_overload_type_authority.py::test_req_qml008_ac03_include_walk_overflow_cannot_hide_sdk_shadow`; `tests/test_qt_overload_consumers.py::test_req_qml008_ac03_unknown_overload_signature_cannot_borrow_known_native_class` | Local uncertainty, duplicate/type/include/transport rejection pass. Aliases, templates, dependent/function-pointer/array/variadic signatures remain unsupported. | +| REQ-QML-011-AC01 | `tests/test_cpp_overload_updates.py::test_req_qml011_ac01_ac04_overload_signature_edit_removal_and_repeat_parity`; `tests/test_qt_overload_consumers.py::test_req_qml008_ac02_and_qml011_ac01_exact_overload_native_consumers` | Local cold/warm/full/manual/watch, exact native ownership and canonical consumer parity pass. | +| REQ-QML-011-AC04 | `tests/test_cpp_overload_updates.py::test_req_qml011_ac04_overload_publication_failure_retains_cohort_and_retries`; `tests/test_cpp_overload_updates.py::test_req_qml011_ac04_partial_constructor_parse_retains_accepted_identity_and_recovery`; `tests/test_cpp_overload_updates.py::test_req_qml011_ac04_schema9_name_id_fixture_migrates_unchanged_sources_to_exact_signatures` | Real OS and injected late publication failure, nonempty real cache retention, repair/repeat and modeled schema-9 name-ID fixture migration pass. This fixture models the old identity seam; it is not a replay of every old release semantic. | + +Former blanket overloaded-constructor rejection is superseded by this accepted +signature profile. Existing ownership rejection tests now use duplicate equivalent +signatures; they retain their exact-owner and corruption assertions. Delegation +keeps the actual body owner without inventing a direct delegation call. The +reference-return callable omission remains a separate INC-QML-29 gap under +REQ-QML-018-AC02/AC03 until its ordinary producer regression passes. + +Source integrity additionally maps REQ-QML-008-AC02/AC03 to +`tests/test_cpp_overload_source_integrity.py::test_req_qml008_ac03_out_of_file_constructor_transport_cannot_bind` +and the exact containing-class/corrupt-file authority controls in that module. +The fourteen collected cases pass against the production binder. Source-size +authority does not turn incomplete SDK include evidence into type authority. + +## Explicit emission observations + +| Acceptance IDs | Exact ordinary tests | State | +| --- | --- | --- | +| REQ-QML-016-AC01 | `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_explicit_unknown_sites_do_not_admit_bare_calls`; `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_long_comment_crlf_bom_and_unicode_keep_original_spans`; `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_explicit_annotation_owns_emission_mechanism`; `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_override_cannot_recover_annotation_by_prefix`; `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_computed_receiver_keeps_full_explicit_source_site` | Local known/unknown/override/mechanism/original-byte source acceptance passes; computed receiver target remains unavailable. | +| REQ-QML-016-AC04 | `tests/test_qt_explicit_emissions.py::test_req_qml016_ac04_unknown_emissions_reload_as_owned_unresolved_sites`; `tests/test_qt_explicit_emission_updates.py::test_req_qml016_ac04_header_rename_remove_restore_and_marker_edits_preserve_observations`; `tests/test_qt_explicit_emission_updates.py::test_req_qml016_ac04_failed_explicit_refresh_retains_products_and_retries`; `tests/test_qt_explicit_emission_updates.py::test_req_qml016_ac04_readonly_declaration_removal_preserves_graph_and_explicit_retry` | Actual facade/build/reload/affected, cold/warm/manual/watch and real write-failure/repaired-repeat parity pass; Reviewed installed suite passes; final profile and limits appear below. | + +## Project and header adoption + +| Acceptance IDs | Exact ordinary tests | State and limits | +| --- | --- | --- | +| REQ-QML-018-AC01 | `tests/test_qt_qmake_adoption.py::test_req_qml018_ac01_pwd_paths_preserve_current_file_and_original_token_spans`; `tests/test_qt_qmake_adoption.py::test_req_qml018_ac01_unrelated_conditional_build_settings_do_not_hide_module`; `tests/test_qt_qmake_adoption.py::test_req_qml018_ac01_relevant_conditions_remain_source_owned_uncertainty`; `tests/test_qt_qmake_adoption_integration.py::test_req_qml018_ac01_direct_facade_project_index_and_reload_preserve_pwd_roles`; `tests/test_qt_qmake_adoption_integration.py::test_req_qml018_ac01_metadata_only_pwd_edit_removal_matches_clean_updates` | Local reader/facade/project-index/reload/query and cold/warm/manual/watch pass. PWD is current-file literal evidence; import hints never extend discovery or configured roots. | +| REQ-QML-018-AC06 | `tests/test_qt_qmake_adoption.py::test_req_qml018_ac06_scope_depth_is_bounded`; `tests/test_qt_qmake_adoption.py::test_req_qml018_ac06_multibyte_path_transport_limit_is_explicit`; `tests/test_qt_qmake_adoption_integration.py::test_req_qml018_ac06_rejected_metadata_preserves_products_and_existing_cache`; `tests/test_qt_qmake_adoption_integration.py::test_req_qml018_ac06_readonly_publication_after_metadata_edit_retains_and_retries`; `tests/test_qt_header_updates.py::test_req_qml018_ac06_header_refresh_denial_preserves_then_recovers` | Local conditional/expansion/root escape, parser and actual read-only publication retention/repaired-repeat pass. Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-018-AC07 | `tests/test_qt_header_adoption.py::test_req_qml018_ac07_whitespace_header_uses_cpp_source_authority`; `tests/test_qt_header_adoption.py::test_req_qml018_ac07_inert_cpp_markers_keep_c_dispatch`; `tests/test_qt_header_adoption.py::test_req_qml018_ac07_objective_c_retains_dispatch_priority`; `tests/test_qt_header_adoption.py::test_req_qml018_ac07_header_prefix_budget_and_digit_separators`; `tests/test_qt_header_updates.py::test_req_qml018_ac07_header_dispatch_mutation_matches_cold_and_warm` | Local lexical/plain-C/Objective-C/original-byte, bounded prefix and extractor-switch lifecycle pass. Header classification grants no native SDK authority. | + +## Declared providers and reference callables + +| Acceptance IDs | Exact ordinary tests | State and limits | +| --- | --- | --- | +| REQ-QML-018-AC03 | `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_declared_expression_selects_backend_api`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_unknown_or_conflicting_factory_never_supplies_provider`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_conditional_provider_declaration_is_not_api_authority`; `tests/test_qt_declared_api_shapes.py::test_req_qml018_ac03_root_shape_must_match_literal_member_operator`; `tests/test_qt_declared_api_shapes.py::test_req_qml018_ac03_direct_producer_keeps_original_cv_shape_and_byte_position` | Exact source-owned API/shape/type/root evidence, malformed/unknown/conflicting/conditional/alias/operator rejection and no execution pass locally. | +| REQ-QML-018-AC04 | `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac04_context_service_subscriptions_have_distinct_sites`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac04_qml_lexical_and_property_shadows_precede_context_provider`; `tests/test_qt_typed_provider_consumers.py::test_req_qml018_ac04_typed_subscriptions_survive_build_reload_and_query`; `tests/test_qt_context_implicit_subscriptions.py::test_req_qml018_ac04_native_parameters_own_nested_provider_and_callback_lookup`; `tests/test_qt_context_implicit_subscriptions.py::test_req_qml018_ac04_nearer_local_callback_keeps_explicit_authority`; `tests/test_qt_declared_proof_integrity.py::test_req_qml018_ac04_ac06_reloaded_notify_proof_retains_mapping_identity` | Source subscription/handler identities, real NOTIFY, original scopes, explicit/local/implicit binding and default/directed reload pass; delivery is not a calls edge. Affected direction closes separately in INC-QML-30. | +| REQ-QML-018-AC05/AC06 | `tests/test_qt_typed_provider_updates.py::test_req_qml018_ac05_cpp_qml_metadata_and_signal_updates_match_clean_rebuild`; `tests/test_qt_typed_provider_updates.py::test_req_qml018_ac06_parser_and_resolver_failure_preserve_then_recover`; `tests/test_qt_declared_proof_integrity.py::test_req_qml018_ac06_typed_hop_rejects_altered_authoritative_declaration`; `tests/test_qt_declared_proof_integrity.py::test_req_qml018_ac06_typed_notify_revalidates_property_accessor_provenance` | Real source/configuration updates, cache/graph/cohort retention and repaired retry; independently corrupted canonical role/owner/file/span/type/accessor proof rejects. Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-008-AC02/AC03; REQ-QML-018-AC02/AC03 | `tests/test_cpp_reference_returns.py::test_req_qml018_ac02_ac03_reference_return_has_canonical_callable`; `tests/test_cpp_reference_returns.py::test_req_qml018_ac02_qualified_reference_declaration_and_definition_keep_one_callable`; `tests/test_cpp_reference_returns.py::test_req_qml018_ac02_ac03_reference_provider_spans_and_consumers_survive_reload`; `tests/test_cpp_reference_returns.py::test_req_qml018_ac02_reference_fallback_requires_one_function_child` | Accepted grammar wrapper, ordinary controls, exact generic identity and original bytes; unsupported/missing/competing child authority rejects. Reference API profile preserves CV without runtime proof. | +| REQ-QML-011-AC01/AC04; REQ-QML-018-AC02/AC03/AC06 | `tests/test_cpp_reference_return_updates.py::test_req_qml018_ac02_ac03_reference_factory_edits_keep_identity_and_drop_stale_provider_edges`; `tests/test_cpp_reference_return_updates.py::test_req_qml018_ac02_ac03_reference_admission_failure_preserves_and_repairs`; `tests/test_cpp_reference_return_updates.py::test_req_qml018_ac02_ac03_readonly_reference_edit_retains_products_and_retries` | Actual C++-only manual/watch, cold/warm, stale-edge removal, parser/late publication/real Windows read-only failure, repaired idempotent retry pass locally. | + +## Affected logical direction + +Callback correspondence additionally assigns REQ-QML-018-AC04/AC06 to +`tests/test_qt_context_callback_integrity.py::test_req_qml018_ac04_ac06_callback_target_cannot_be_substituted`, +`tests/test_qt_context_callback_integrity.py::test_req_qml018_ac04_ac06_local_callback_precedes_same_named_qml_function`, +`tests/test_qt_context_callback_integrity.py::test_req_qml018_ac06_callback_borrowed_provenance_is_revalidated` +and `tests/test_qt_context_callback_integrity.py::test_req_qml018_ac04_callback_lookup_retains_object_scope`. +These exercise actual raw and default/directed build/JSON/reload, derived target +substitution, borrowed source/span/scope corruption and local/qualified controls. + +| Acceptance IDs | Exact ordinary tests | State and limits | +| --- | --- | --- | +| REQ-QML-018-AC04; REQ-QML-017-AC04; REQ-QML-011-AC01 | `tests/test_qt_affected_direction.py::test_native_provider_affected_uses_accepted_serialized_direction`; `tests/test_qt_affected_direction.py::test_logical_member_seed_finds_caller_without_reporting_seeded_method`; `tests/test_qt_affected_direction.py::test_legacy_edges_without_direction_keep_existing_traversal`; `tests/test_qt_affected_direction.py::test_logical_direction_depth_cycles_and_repeated_query_preserve_graph` | Local real build/JSON reload/CLI, directed/undirected insertion reversal, legacy, depth/filter/source-site and graph-preservation proof passes. | +| REQ-QML-018-AC06; REQ-QML-011-AC04 | `tests/test_qt_affected_direction.py::test_corrupt_direction_cannot_fall_back_to_physical_incoming`; `tests/test_qt_affected_direction.py::test_qt_owner_promotion_rejects_partial_or_foreign_direction`; `tests/test_qt_affected_direction.py::test_typed_reload_preserves_corrupt_dependency_direction_for_rejection`; `tests/test_qt_affected_direction.py::test_typed_reload_preserves_corrupt_owner_direction_for_rejection`; `tests/test_qt_affected_direction.py::test_boolean_marker_cannot_coerce_integer_endpoint_identity`; `tests/test_qt_affected_direction.py::test_parallel_edges_keep_valid_filtered_mechanism_after_invalid_direction` | Complete/partial/foreign/ill-typed/directed-contradictory corruption cannot authorize a dependency or source-owner link. Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-018-AC04/AC06; REQ-QML-011-AC01/AC04 | `tests/test_qt_affected_direction_updates.py::test_native_affected_direction_cold_warm_update_removal_recovery_and_repeat` | Actual manual/watch source edits, removal, repair and cold/warm/repeated parity pass locally. | + +## JSON direction integrity and combined adoption + +| Acceptance IDs | Exact ordinary tests | State and limits | +| --- | --- | --- | +| REQ-QML-013-AC01/AC03; REQ-QML-018-AC04/AC06 | `tests/test_qt_json_direction.py::test_json_direction_rejection_preserves_previous_bytes_and_safe_diagnostic`; `tests/test_qt_json_direction.py::test_json_directed_contradiction_rejects_before_first_file_exists`; `tests/test_qt_json_direction.py::test_native_source_proof_export_reload_consumer_survives_rejection_and_repair`; `tests/test_qt_json_direction.py::test_undirected_reversed_storage_retains_real_qml_dependency_direction` | Local real writer/native proof/reload/affected, force/rejection/prior-output/repair and accepted insertion reversal pass. | +| REQ-QML-013-AC01/AC03; REQ-QML-018-AC06 | `tests/test_qt_json_direction.py::test_external_direction_rejection_precedes_file_or_database_connection`; `tests/test_qt_json_direction.py::test_external_bidirectional_wrapper_honors_logical_pair_but_json_requires_native_direction`; `tests/test_qt_json_direction.py::test_legacy_unmarked_edges_and_valid_self_pairs_preserve_native_identity` | External preflight/call boundary and compatible logical wrapper behavior pass; no live database engine proof is claimed. | +| REQ-QML-011-AC04; REQ-QML-018-AC06 | `tests/test_qt_json_direction_updates.py::test_rejected_direction_clustered_publication_retains_products_cache_and_repairs` | Actual clustered manual/watch serializer rejection preserves seven durable products and real source-cache bytes; repair equals clean source contracts and repeat is byte-identical. | +| REQ-QML-018-AC01–AC05/AC07 | `tests/test_qt_combined_adoption.py::test_req_qml018_ac05_combined_configured_project_matches_cold_warm_and_updates` | Public combined CMake/qmake, whole root/safe application subroot, configured module, inherited emission/exact constructor/typed API/subscription, source cold/warm/manual/watch and QML-only callback edit parity pass locally. Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-018-AC06 | `tests/test_qt_combined_adoption_safety.py::test_req_qml018_ac06_combined_rejection_retains_cache_products_and_retries`; `tests/test_qt_combined_adoption_safety.py::test_req_qml018_ac06_combined_readonly_destination_preserves_then_retries` | Missing parser, malformed source, unsafe import root and actual Windows read-only graph replacement retain prior products/cache and succeed on corrected retry/idempotent repeat. Reviewed installed suite passes; final profile and limits appear below. | + +## First raw-update repeat + +| Acceptance IDs | Exact ordinary tests | State and limits | +| --- | --- | --- | +| REQ-QML-011-AC01; REQ-QML-018-AC05/AC06 | `tests/test_qt_initial_repeat.py::test_cold_combined_no_cluster_repeat_keeps_bytes_mtime_and_changed_source`; `tests/test_qt_initial_repeat.py::test_cold_generic_no_cluster_repeat_does_not_rewrite_first_graph` | Actual cold CLI/watch, CMake/qmake, whole-root/subroot, bytes/mtime repeat and subsequent real edit pass locally; Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-011-AC01/AC04; REQ-QML-018-AC06 | `tests/test_qt_initial_repeat.py::test_known_absent_and_empty_run_lists_compare_equal_without_mutation`; `tests/test_qt_initial_repeat.py::test_nonempty_or_wrong_type_run_lists_remain_observable`; `tests/test_qt_initial_repeat.py::test_unknown_metadata_and_source_facts_still_compare_different`; `tests/test_qt_initial_repeat.py::test_real_raw_publication_failure_retains_prior_then_recovers` | Bounded normalization, unchanged input, meaningful/malformed/source proof controls and real staged replacement rejection/repair pass locally. | + +## C++ file proof and candidate preflight + +| Acceptance | Exact automated coverage | Current evidence | +| --- | --- | --- | +| REQ-QML-020-AC01/AC03; REQ-QML-008-AC02 | `tests/test_qt_cpp_file_roles.py::test_req_qml020_ac01_ac03_cpp_file_transport_retains_exact_file_identity`; `tests/test_qt_cpp_file_roles.py::test_req_qml008_ac02_cpp_file_role_preserves_separate_constructor_type_authority` | Current, legacy, incomplete, fresh and reloaded file proof; complete-record constructor authority stays separate. Local shared suite passes; Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-020-AC02 | `tests/test_qt_cpp_file_roles.py::test_req_qml020_ac02_cpp_file_transport_cannot_authorize_corrupted_file_role`; `tests/test_qt_cpp_file_roles.py::test_req_qml020_ac02_cpp_file_joins_read_no_source_and_execute_no_corpus` | Actual borrowed transport at both joins rejects corruption without mutating, rereading or executing the corpus. Local shared suite passes; Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-013-AC03; REQ-QML-011-AC04; REQ-QML-018-AC06 | `tests/test_qt_update_direction_integrity.py::test_actual_corrupt_candidate_refuses_shortcut_publication_and_repairs`; `tests/test_qt_update_direction_integrity.py::test_complete_reverse_pair_remains_accepted_on_undirected_storage` | 68 local manual/watch raw/clustered force/default failure/recovery/control cases pass; Reviewed installed suite passes; final profile and limits appear below. | + +## Prior import and repeated event ownership + +| Acceptance | Exact ordinary coverage | Evidence | +| --- | --- | --- | +| REQ-QML-020-AC03; REQ-QML-011-AC01/AC04 | `tests/test_qt_cpp_file_role_updates.py::test_req_qml020_ac03_cpp_transport_edits_and_removal_preserve_exact_membership`; `tests/test_qt_cpp_file_role_updates.py::test_req_qml011_ac04_cpp_deleted_import_keeps_still_referenced_external_endpoint`; `tests/test_qt_cpp_file_role_updates.py::test_req_qml011_ac04_cpp_import_cleanup_preserves_same_label_source_and_semantic_roles`; `tests/test_qt_cpp_file_role_updates.py::test_req_qml011_ac04_cpp_import_cleanup_requires_exact_prior_source_proof` | Manual/watch cold/warm deletion/restoration and exact raw prior-proof authority; source passes; Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-018-AC06; REQ-QML-011-AC04 | `tests/test_qt_cpp_file_role_updates.py::test_req_qml018_ac06_cpp_orphan_cleanup_failed_publication_preserves_products_and_retries`; `tests/test_qt_cross_event_occurrences.py::test_req_qml018_ac06_cross_event_publication_failure_retains_prior_and_retries` | Actual Windows OS rejection, prior products and nonempty source cache retained, corrected retry/repeat pass; Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-016-AC02/AC04; REQ-QML-017-AC02/AC04 | `tests/test_qt_cross_event_occurrences.py::test_req_qml016_ac02_ac04_cross_language_occurrences_keep_own_transport` | 1–16 mixed repeated connect/disconnect sites, both graph modes and QML/C++ directions, original span and bounded transport through production assembly/reload pass. | +| REQ-QML-011-AC03/AC04; REQ-QML-018-AC06 | `tests/test_qt_cross_event_occurrences.py::test_req_qml011_ac04_cross_event_edits_removal_and_repeat_match_clean`; `tests/test_qt_cross_event_occurrences.py::test_req_qml011_ac03_event_policy_refreshes_unchanged_source` | Actual C++/QML edits, stale removal, repair/full parity/unrelated Python and old-checkpoint refresh pass; Reviewed installed suite passes; final profile and limits appear below. | + +## Bridge mechanisms and first minted provenance + +| Acceptance | Exact ordinary coverage | Source result | +| --- | --- | --- | +| REQ-QML-018-AC04/AC06; REQ-QML-017-AC02/AC04; REQ-QML-010-AC03/AC04 | `tests/test_qt_bridge_mechanism_integrity.py::test_source_mechanisms_survive_builder_export_and_reload`; `tests/test_qt_bridge_mechanism_integrity.py::test_source_mechanism_substitution_is_rejected_before_graph_assembly`; `tests/test_qt_bridge_mechanism_integrity.py::test_context_access_revalidates_original_read_or_call`; `tests/test_qt_bridge_mechanism_integrity.py::test_endpoint_role_cannot_be_changed_to_repair_relation`; `tests/test_qt_bridge_mechanism_integrity.py::test_cross_language_flow_cannot_be_reversed_by_metadata`; `tests/test_qt_bridge_mechanism_integrity.py::test_reverse_reflection_repeats_original_supported_operation` | 166 new cases pass across 18 mechanisms; typed `uses` paths cannot bypass assembly proof. Reviewed installed suite passes; final profile and limits appear below. | +| REQ-QML-011-AC01/AC04; REQ-QML-018-AC05/AC06 | `tests/test_qt_publication_stub_origin.py::test_first_raw_stub_has_explicit_semantic_origin`; `tests/test_qt_publication_stub_origin.py::test_first_raw_import_repeat_preserves_bytes_mtime_and_genuine_edit`; `tests/test_qt_publication_stub_origin.py::test_existing_authored_endpoint_is_not_stamped_by_minting`; `tests/test_qt_publication_stub_origin.py::test_first_stamped_cpp_import_deletion_restoration_and_repeat`; `tests/test_qt_publication_stub_origin.py::test_new_stub_replacement_failure_retains_cohort_cache_then_retries` | 22 new cold manual/watch C++/Python, origin-preservation and injected replacement-failure/retry cases pass. Actual Windows denial is separately mapped above. Reviewed installed suite passes; final profile and limits appear below. | + +## Final adoption delivery + +Profile: Windows x64, Python 3.12.14, tree-sitter 0.25.2, +tree-sitter-language-pack 0.11.0; Qt 6 source inputs with CMake and qmake. The +reviewed tree is `9f0ae4ea75f47539df8ec8a66f1493b9e7f52b2f`. All 179 Python payloads +match the built wheel and installed package; the isolated installed selection +passes 3,024 tests with 36 historical optional/platform skips. None of the newly +added regression cases skips. Exact commands, digest and full-gate exceptions are +in [validation](../docs/qt-qml/VALIDATION.md#final-adoption-delivery). + +| Criterion | Independently assigned production evidence | Current applicability and result | +| --- | --- | --- | +| REQ-QML-018-AC01 | Project/header table above: direct reader, facade, project index, reload and actual metadata-only updates; combined test and installed CMake/qmake whole/subroot CLI profiles | Locally Verified; arbitrary expansion/build execution excluded | +| REQ-QML-018-AC02 | Native syntax/cache upgrade and reference-return tables above: hand-checked original-byte facts, malformed/inert/generic controls and source/installed consumers | Locally Verified; unsupported relationships stay unresolved | +| REQ-QML-018-AC03 | `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_declared_expression_selects_backend_api`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_unknown_or_conflicting_factory_never_supplies_provider`; declaration-shape and producer-integrity modules mapped above, executed in source and installed selections | Locally Verified; accepted pointer/lvalue-reference declarations only, no factory execution or runtime conversion proof | +| REQ-QML-018-AC04 | `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac04_typed_service_call_and_property_are_source_owned`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac04_context_service_subscriptions_have_distinct_sites`; `tests/test_qt_typed_provider_consumers.py::test_req_qml018_ac04_typed_subscriptions_survive_build_reload_and_query`; affected direction, callback/mechanism proof and repeated-site tables above | Locally Verified in source and installed selections; no inferred runtime delivery | +| REQ-QML-018-AC05 | `tests/test_qt_combined_adoption.py::test_req_qml018_ac05_combined_configured_project_matches_cold_warm_and_updates`; `tests/test_qt_typed_provider_updates.py::test_req_qml018_ac05_cpp_qml_metadata_and_signal_updates_match_clean_rebuild`; four actual isolated installed CLI profiles, each initial/repeat exit 0 and identical graph bytes | Locally Verified; hand-checked CMake/qmake whole/subroot facts plus cold/warm/manual/watch mutation parity. Other hosted OS/Python cells unexecuted | +| REQ-QML-018-AC06 | Both combined safety tests above; `tests/test_qt_typed_provider_updates.py::test_req_qml018_ac06_parser_and_resolver_failure_preserve_then_recover`; JSON/preflight/repeat/orphan/publication failure tables above; actual Windows read-only denial complements injected replacement failure | Locally Verified in source and installed selections; prior products and nonempty successful cache, diagnostics, repair/repeat, no execution and bounded roots. Missing optional parser is separately exercised, not counted as a skip | +| REQ-QML-018-AC07 | Header-discovery/update table above: whitespace/BOM/CRLF/Unicode, comment/string/C/inconclusive/Objective-C controls and actual header edits; source and installed selections | Locally Verified; headers without accepted C++ evidence keep established dispatch | + +INC-QML-11 inherited endpoints and INC-QML-15 exact constructor identity retain +their existing individual assignments above and pass in the reviewed installed +selection. The final full source command records 8,630 passed, 59 failed, +178 skipped and three warnings; the exact 59 failure identities equal baseline +5c0f2ca. Full Pyright has no added diagnostic identities but remains failed. +Current hosted platform jobs, live service delivery and native browser/device +procedure have not executed for this source. A global Verified or release-ready +status cannot be inferred from the bounded local evidence. + +## Current-upstream integration — INC-QML-49 + +Source checkpoint: 65103f8; incoming v8:35adf43/package 0.9.76. Corrected integration +proof passes at the recorded 8b6c9d2 checkpoint below. The plan's acceptance matrix identifies affected IDs before +implementation; concrete tests, commands, individual outcomes, baseline gaps and +reviewed source/base/checkout identity are recorded here as they complete. +Previous hosted results remain immutable checkpoints. Old skips or aggregate +counts cannot establish the new joint-boundary criteria. + +The frozen native profile is Windows x64, Python 3.12.14, package 0.9.76, +tree-sitter 0.25.2/language-pack 0.11.0, AST schema 13 and Qt policy 22. +The ordinary joint selection executes 99 cases: **83 passed, sixteen explicit +POSIX traversal exclusions, zero failures**. A final context-only correction is +verified separately by all six native admission cases, with their exact code, +empty unavailable context, fragmentwise backend-body absence and repaired repeat +assertions. POSIX link/`..` controls require actual POSIX runner execution. + +| Affected acceptance IDs | Exact production tests and profile result | +| --- | --- | +| REQ-QML-002-AC02/AC03/AC04; REQ-QML-020-AC01 | `tests/test_upstream_qt_discovery_integration.py::test_req_qml002_ac02_ac03_detect_and_collect_share_named_metadata_and_copy_exclusions`; `tests/test_upstream_qt_discovery_integration.py::test_req_qml002_ac03_ac04_followed_metadata_keeps_lexical_scope_and_rejects_foreign_input`; direct cold/warm, real manual/watch and malformed-metadata cases in the same module. Eleven native cases pass; default/contained-follow, ignores and installed-copy exclusion remain production boundaries. | +| REQ-QML-003-AC04; REQ-QML-011-AC03/AC04; REQ-QML-018-AC05/AC06 | `tests/test_upstream_qt_cache_integration.py::test_req_qml003_ac04_qml018_ac05_old_namespaces_miss_and_refresh`; `tests/test_upstream_qt_cache_integration.py::test_req_qml003_ac04_qml018_ac05_mixed_cold_warm_serialized_facts`; `tests/test_upstream_qt_cache_integration.py::test_req_qml011_ac03_ac04_qml018_ac06_upgrade_failure_retains_repairs_repeats`. Six native cases pass, with old schemas 5/12 and both package versions, receiver-shadow/Qt fact parity, semantic byte retention and actual replacement failure/recovery. | +| REQ-QML-010-AC02; REQ-QML-018-AC04/AC06 | `tests/test_upstream_qt_export_integration.py::test_req_qml_010_ac02_original_parallel_links_retain_qml_direction`; `tests/test_upstream_qt_export_integration.py::test_req_qml_018_ac06_preserved_invalid_link_refuses_replacement`; `tests/test_upstream_qt_export_integration.py::test_req_qml_018_ac04_real_recluster_keeps_parallel_and_qml_links`. Ten native cases pass, preserving distinct generic mechanisms, actual QML direction/provenance, prior bytes/mtime, repaired repeat and real offline reclustering. | +| REQ-QML-010-AC03; REQ-QML-018-AC04 | `tests/test_upstream_qt_path_integration.py::test_req_qml_010_ac03_qualified_native_path_preserves_direction`; `tests/test_upstream_qt_path_integration.py::test_req_qml_018_ac04_duplicate_qualified_target_refuses_guess`. Three native cases pass through the real CLI, qualified file/symbol and exact-ID selection, same-name decoys, explicit duplicate rejection, directed/undirected controls and unchanged durable input. | +| REQ-QML-003-AC01/AC02/AC04; REQ-QML-011-AC01/AC04; REQ-QML-018-AC02 | `tests/test_upstream_qt_shared_integration.py::test_req_qml003_ac01_ac02_ac04_union_and_qt_native_cold_warm_reload`; `tests/test_upstream_qt_shared_integration.py::test_req_qml011_ac01_ac04_union_edit_removes_stale_member_keeps_native_endpoint`. Four native CMake/qmake cases pass with original spans, distinct same-named C++ union/native method ownership, real edits, stale removal and repeated output. | +| REQ-QML-020-AC01/AC02/AC03; REQ-QML-003-AC04; REQ-QML-011-AC03/AC04; REQ-QML-018-AC05/AC06 | `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac02_typed_producers_keep_distinct_alias_facts_and_original_spans`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac02_followed_metadata_facade_keeps_every_owner_cold_warm_and_reload`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac03_typed_alias_updates_remove_stale_facts_and_match_full_rebuild`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac03_alias_read_failure_retains_cohort_cache_and_repairs`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac02_membership_rejects_another_walked_owner_of_same_physical_input`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac03_policy21_alias_products_refresh_retain_and_recover`. All 43 native cases pass, including BOM/CRLF/Unicode spans, nested owner/target references, actual junction/root aliases, foreign inputs, malformed lexical context and native short leaf spelling. | +| REQ-QML-012-AC01/AC02/AC04; REQ-QML-020-AC02 | `tests/test_qt_source_identity_admission.py::test_req_qml020_ac02_native_admission_failure_keeps_its_code_and_recovers`. Six native production-reader cases pass after the final correction. `tests/test_qt_source_identity_admission.py::test_req_qml020_ac02_dotdot_input_keeps_actual_physical_target_and_containment` has sixteen Windows exclusions because actual POSIX physical link/`..` traversal is its test boundary; all sixteen cases pass individually in each recorded Linux lane below. | +| REQ-CORE-004-AC02 | `tests/test_extract.py::test_python_external_calls_survive_real_incremental_context` passes in the corrected native profile; all ten upstream extraction assertions remain structurally unchanged. The exact case is included in native hosted selection. | + +The joint command is: + +```text +python -X utf8 -m pytest tests/test_upstream_qt_cache_integration.py tests/test_upstream_qt_discovery_integration.py tests/test_upstream_qt_export_integration.py tests/test_upstream_qt_path_integration.py tests/test_upstream_qt_shared_integration.py tests/test_qt_followed_metadata_alias.py tests/test_qt_source_identity_admission.py -q --tb=short -rs --junitxml=joint.xml +``` + +Adjacent profiles pass 601 upstream/shared cases with eleven capability skips, +172 cache/stat-index/Python/Qt cases with eight file-symlink skips, and 271 +export/path/direction cases with no skips. Counts overlap and are not summed. +Upstream multi-root stat-index warnings are retained. The original collector +leaks installed copies under both profiles; both immutable parent cache modules +reuse an old namespace; the old export loses a parallel relation and old path +lookup fails qualified identity. The prior full producer and current pre-fix +typed reader omit walked metadata; twenty ordinary alias cases fail before the +correction. Replays use actual immutable modules with current support imports, +not claims of full historical dependency equivalence or a checkout swap. + +The corrected source wheel contains **185 Python payloads**, each byte-identical +to source and both isolated QML/core installs. Its SHA-256 is +`870a56e35e3a18afde2f7c13811e6e3fa8af53529f485a4399521133046d9d01`. +Both isolated installed smokes and all three reviewed-artifact tests pass. +Production and original coverage bytes remain unchanged during artifact proof. +The preliminary wheel precedes the final diagnostic correction and is not used +as final acceptance. Hosted wheels retain their own independently recorded digests. + +Whole Ruff passes. All five skillgen validators pass, including 134 generated +artifacts. Frozen explicit-interpreter Pyright remains **605 errors, zero warnings**; +all new integration modules have zero diagnostics. Four errors move with an +upstream responsibility transfer and one Dart OptionalSubscript is reproduced +from the immutable incoming source. This is a failed existing contribution gate, +not successful typing. The final 197-distribution inventory changes only the +Graphify package version from the historical profile. No assertion, required +check, protection or historical commit is weakened or rewritten. Advisory +security and physical application/device procedures remain separate gaps. + +Normal fork PR proof targets the exact incoming 35adf43 base on +`codex/qml-upstream-base`; the integration branch remains in the canonical +workspace. All prior PRs and the verified 65103f8 branch are preserved. No +upstream PR, merge, deployment or default-branch update is performed. Terminal +current head/base/checkout identities, cases, artifacts and cleanup remain the +hosted exit gate under REQ-CORE-004-AC01–AC04. + +### First integrated hosted checkpoint — dbc4859 + +Draft [PR 7](https://github.com/SlinkyRamey/graphify/pull/7) binds source +`dbc4859f454f9478ac9596559a75efef5f10664d`, base +`35adf432b9d50f6f3d530ab5d7ec316819ef081c` and actual synthetic checkout +`db0b4dd0829082dca1e222bc90a85571871fb4bd`. +[CI 37287514274](https://github.com/SlinkyRamey/graphify/actions/runs/37287514274) +fails the stale platform selector guard in Python 3.14; the other three full +Linux jobs are cancelled, so those profiles remain incomplete. +The completed Linux 3.14 profile reports 9,354 passed, one failed and 121 skipped; +all 34 joint cases, 40 applicable alias cases, sixteen real POSIX link/`..` +controls and five service/wheel cases pass individually. All four retained +Linux wheel payloads match the 185 reviewed source modules, and every disposable +service container is confirmed absent, including the cancelled lanes. The +cancelled suites have no completed JUnit/pytest-exit/integrity evidence. +[Wheel matrix 37287514235](https://github.com/SlinkyRamey/graphify/actions/runs/37287514235) +exposes the same guard in native source lanes and a distinct native metadata +fixture seam. INC-QML-51/52 own their corrections; this is a failed integration +checkpoint, not hosted release acceptance. +All four macOS source lanes report one failed, 2,360 passed and 64 skipped; +each Windows source lane reports two failed, 2,404 passed and nineteen skipped. +The four Ubuntu wheel lanes each pass three artifact tests. All 24 isolated +optional/core installed smokes pass. Those wheel jobs retain no per-case JUnit, +wheel-digest or source/dependency artifact; native platform tests import the +checkout and remain source evidence distinct from the installed smokes. + +The [native Windows job](https://github.com/SlinkyRamey/graphify/actions/runs/37287514274/job/111689834160) +passes 923 cases with 37 explicit exclusions and pytest exit zero. All 34 joint +integration, 43 followed-metadata alias, six native failure/recovery and exact +INC-CORE-10 cases pass individually. The exclusions retain 21 previous cases +plus sixteen actual POSIX link/`..` controls. Artifact 11335540466's API digest +and downloaded ZIP SHA-256 both equal +`1ce081338cf8963694e37b52b66a949c3149c3ceae3a7ca43b2b158e2815868c`. +The retained preflight proves both admitted Bash/sh consumers, Python 3.12.10 +and Node 24.19.0; checkout, tracked inputs and all 197 dependencies are unchanged. +The skill job passes all five validators and 134 artifact checks. Advisory +security has an API-success conclusion but both raw commands return exit one: +Bandit reports four high, eight medium and 112 low findings; pip-audit reports +fifteen raw vulnerability rows across three packages. These are failed advisory +commands, not a clean security scan. + +### Platform selection guard correction — INC-QML-51 + +Affected assignments: REQ-QML-001-AC01/AC03, REQ-QML-014-AC01/AC03/AC04 and +REQ-CORE-004-AC03 map to +`tests/test_qml_platform_matrix.py::test_wheel_runner_keeps_optional_core_and_isolated_offline_smoke_boundaries`. +The original production runner correctly admits the new upstream/Qt modules; +its guard incorrectly requires the former two-prefix literal. Local original +platform execution reports one passed/one failed test. Corrected execution of +both platform tests passes on native Python 3.12.14, with zero Ruff/Pyright +diagnostics. The guard now compiles only the trusted runner's actual selection +expression and exercises real temporary files in native and artifact-only +modes, retaining every install/isolation/offline assertion. It rejects unrelated +language modules, runner helpers and wrong extensions; deterministic selected +names include the joint regression prefix. +The owned glob seam reverses actual fixture entries; stable ordering therefore +depends on the production sort rather than the host's directory enumeration. + +Executed command: + +```text +python -X utf8 -m pytest tests/test_qml_platform_matrix.py tests/test_qml_wheel_artifact.py -q --tb=short --junitxml=platform.xml +``` + +Without the reviewed-wheel variable this command passes two tests and explicitly +skips three artifact tests; that run is guard evidence only. The retained reviewed +wheel procedure and corrected hosted revision own artifact acceptance. +The same command with the final reviewed-wheel variable passes all five tests +without skips. The wheel remains byte-identical to the runtime checkpoint; +these corrections change test evidence only. + +### Native metadata-growth fixture correction — INC-QML-52 + +REQ-QML-002-AC03, REQ-QML-012-AC01/AC04 and REQ-QML-014-AC02/AC03/AC04 map to +`tests/test_qt_metadata_boundaries.py::test_cmake_scope_limit_and_source_file_growth_are_rejected`. +The original fake stat carries only size and causes native identity admission +to reject before the intended read-growth boundary. Correcting the stat witness +must retain native mode/device/inode and every available non-size stat field; +the original cmake_scope_limit and metadata_size_limit assertions remain +authoritative. The original ordinary case fails on native Python 3.14.7 with +native_source_spelling_unavailable before the intended read boundary. After +the fixture correction all 35 module cases pass without skips on native +3.10.21, 3.12.14, 3.13.15 and 3.14.7. The fixture checks its understated size, +every real non-size stat field and actual same-file identity, retaining the +original two rejection assertions. It measures 99 physical lines. Source +runtime, dependencies and original coverage are unchanged; corrected hosted +source outcomes pass in the verified integrated checkpoint below. + +Executed per-interpreter command: + +```text +python -X utf8 -m pytest tests/test_qt_metadata_boundaries.py -q --tb=short --junitxml=metadata.xml +``` + +## Verified integrated hosted checkpoint — 8b6c9d2 + +Draft [PR 7](https://github.com/SlinkyRamey/graphify/pull/7) source +`8b6c9d2bd4a89377829cb09c0737744aebccfb97` targets exact incoming upstream base +`35adf432b9d50f6f3d530ab5d7ec316819ef081c`. Both normal `pull_request` workflows +test actual synthetic checkout `0863b2b7e5c63a27b6d5185490b8b610cc997424`; +API parent identity and each job's retained checkout identity agree. +[CI 37289566337](https://github.com/SlinkyRamey/graphify/actions/runs/37289566337) +and [QML wheel 37289566340](https://github.com/SlinkyRamey/graphify/actions/runs/37289566340) +both conclude success on attempt one. All nineteen validation jobs pass. +The prior dbc4859 failures and cancelled profiles remain separate evidence. + +| Profile | Passed | Explicit exclusions | Actual evidence boundary | +| --- | ---: | ---: | --- | +| Full Ubuntu / Python 3.10.22 | 9,356 | 120 | Source suite, JUnit, pytest exit zero, retained identity/integrity and cleanup | +| Full Ubuntu / Python 3.12.3 | 9,355 | 121 | Same source/retention boundary | +| Full Ubuntu / Python 3.13.16 | 9,355 | 121 | Same source/retention boundary | +| Full Ubuntu / Python 3.14.8 | 9,355 | 121 | Same source/retention boundary | +| Focused native Windows / Python 3.12.10 | 923 | 37 | Actual Cmd/PowerShell/Bash/sh consumers, JUnit, uploaded report and input/dependency identity | +| Four Windows QML lanes / Python 3.10/3.12/3.13/3.14 | 2,406 each | 19 each | Checkout source profiles and separate neutral isolated installed smokes | +| Four macOS QML lanes / Python 3.10/3.12/3.13/3.14 | 2,361 each | 64 each | Checkout source profiles and separate neutral isolated installed smokes | +| Four Ubuntu wheel lanes / Python 3.10/3.12/3.13/3.14 | 3 each | 0 | Reviewed-artifact contracts and separate neutral isolated installed smokes | + +Every Linux lane individually passes all 128 applicable integration assignments: +34 joint upstream/Qt, 40 alias, sixteen real POSIX link/`..`, two platform guards, +35 metadata-boundary and the exact INC-CORE-10 subprocess case. Nine new +Windows-only native/short-leaf controls retain platform skips. The five actual +FalkorDB/wheel cases also pass. The native Windows artifact individually passes +all 34 joint, 43 alias, six native-admission and the same subprocess case. +Its 37 exclusions are the prior 21 plus sixteen POSIX-only controls. Full skip +inventories retain their actual reasons; version-specific `tomli`, exhausted +bundle-fallback and absent optional graspologic comparison controls are not +relabelled as passes. The native partition test still executes independently. + +All five source/native archives have downloaded SHA-256 equal to the GitHub API +digest. Linux artifacts are 11336600508 (3.10), 11336267743 (3.12), 11336132399 +(3.13) and 11335669170 (3.14). The native artifact 11336246160 digest is +`c280b9aa51311e5bf02874cc90f0eea7bbeb54e759c44e09c8290d8a5a7d36ef`. +Its actual uploaded preflight proves both admitted Git Bash/sh consumers use +the intended checkout Python and Node 24.19.0. Linux dependency inventories +contain 185/195/174/174 distributions respectively; Windows contains 197. +Each before/after inventory, tested checkout and tracked input identity is +unchanged. All twelve Linux integrity flags pass. Real service readiness, +test-graph deletion and absence of every job-owned container are confirmed; +all four end-to-end install steps pass. + +Each full-source Linux wheel's 185 Python payloads equal reviewed source bytes +and actual synthetic-checkout Git blobs. Their SHA-256 digests, in lane order +3.10/3.12/3.13/3.14, are: + +- `1be83a0da3a89eb8824dd8a76b7445cba5a172f2cf52b76c5701dacf1ca375ca` +- `cd6e12dd0d8131651300d06a9c3d0baad46bc788f78fdb258eda872799864291` +- `3803242269198f47e6565dd6a9fe0ae0baecf5db323de430c4dc0afdb07661a6` +- `02f3bf909e13aa4f4aff0fb402c9ecc6a8fc5d58038f61b8f37ab4302100485f` + +All 24 optional/core installed smokes pass from neutral directories with isolated +interpreters. Windows/macOS platform tests import the checkout; their workflow +retains aggregate logs, without per-case JUnit, wheel-digest or byte-attestation +archives. Those limits are not filled by a source total or another job's wheel. +Linux JUnit evidence establishes individual corrected guard and metadata cases; +Linux/native JUnit establishes their applicable integration and subprocess cases. +Skill generation passes five validators and 134 +artifact checks. Frozen local source typing still fails with 605 errors; new +integration/changed fixture modules have zero scoped diagnostics. + +Advisory security still has successful job metadata but failed raw commands: +four high/eight medium/112 low Bandit findings and fifteen dependency rows across +three packages. All twelve printed medium/high owner functions match immutable +upstream 35adf43 AST exactly; the 112 aggregate low findings are not individually +classified. A clean security or typing contribution gate is not established. +Physical browser/device/application interaction, Qt runtime effects, released +upstream delivery and merging remain outside this hosted source profile. + +The final plan review finds no additional reproduced integration defect; +INC-QML-53 and REQ-QML-022 remain unallocated. AST-only refresh after the source +corrections completes with 23,247 nodes/53,843 edges and the known malformed Luau +fixture warning. No graph commit stamp is inferred from that update command. +This evidence completion changes documentation only. The later documentation +head receives its own normal PR validation; this immutable checkpoint does not +claim its unknown future head already passed. AI commit attribution and the +original coverage artifact remain preserved; no upstream PR or merge is performed. diff --git a/tests/audit/probe_qt_loader_forms.py b/tests/audit/probe_qt_loader_forms.py new file mode 100644 index 0000000000..7031837fad --- /dev/null +++ b/tests/audit/probe_qt_loader_forms.py @@ -0,0 +1,66 @@ +"""Opt-in INC-QML-20 audit of literal loader provenance, not runtime execution. + +Invoke explicitly with pytest: normal discovery excludes ``probe_*.py``. These +strict assertions expose coverage omissions under REQ-QML-017-AC01/AC04; missing +facts remain unresolved rather than linking a guessed component or property. +""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites + + +@pytest.mark.parametrize("loader", ["engine_load", "engine_url_constructor", "component_load_url"]) +def test_req_qml017_ac01_literal_loader_keeps_component_and_property_provenance(tmp_path, loader): + """Equivalent literal loaders must preserve exact source and persisted joins.""" + url = (tmp_path / "Main.qml").as_uri() + loader_expressions = { + "engine_load": f'engine.load(QUrl("{url}"))', + "engine_url_constructor": f'QQmlApplicationEngine engine(QUrl("{url}"))', + "component_load_url": f'component.loadUrl(QUrl("{url}"))', + } + expression = loader_expressions[loader] + forms = { + "engine_load": f'QQmlApplicationEngine engine; {expression}; auto root = engine.rootObjects().first();', + "engine_url_constructor": f'{expression}; auto root = engine.rootObjects().first();', + "component_load_url": f'QQmlEngine engine; QQmlComponent component(&engine); {expression}; auto root = component.create();', + } + source = 'void use() { ' + forms[loader] + ' root->property("value"); }' + result = analysis(tmp_path, { + "access.cpp": source, + "Main.qml": 'import QtQml\nQtObject { property int value: 1 }', + }) + graph = build_from_json(result, root=tmp_path) + output = tmp_path / "proof.json" + assert to_json(graph, {}, str(output), force=True) + reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + + # Check the source-owning loader occurrence, not a URL-less constructor or + # a generic C++ call that lacks special component provenance. + original = source.encode() + loaders = [node for node in sites(result, "qml_load") + if original[qt_metadata(node)["span"]["start_byte"]: + qt_metadata(node)["span"]["end_byte"]] == expression.encode()] + assert len(loaders) == 1, { + "loader": loader, + "recognized_operations": [qt_metadata(node)["operation"] for node in sites(result, "qml_load")], + } + metadata = qt_metadata(loaders[0]) + assert metadata["status"] == "resolved" + assert metadata["target_id"] in reloaded + assert reloaded.has_edge(loaders[0]["id"], metadata["target_id"]) + assert reloaded.edges[loaders[0]["id"], metadata["target_id"]]["context"] == "qt_cpp_qml_load" + + access = sites(result, "qml_access", "property")[0] + metadata = qt_metadata(access) + assert metadata["status"] == "resolved" + assert original[metadata["span"]["start_byte"]:metadata["span"]["end_byte"]] == b'root->property("value")' + assert reloaded.has_edge(access["id"], metadata["target_id"]) + assert reloaded.edges[access["id"], metadata["target_id"]]["context"] == "qt_cpp_qml_property_read" diff --git a/tests/audit/probe_qt_native_type_shadowing.py b/tests/audit/probe_qt_native_type_shadowing.py new file mode 100644 index 0000000000..254cfac71c --- /dev/null +++ b/tests/audit/probe_qt_native_type_shadowing.py @@ -0,0 +1,221 @@ +"""Opt-in audit of native type authority through completed graph publication. + +Invoke this ``probe_`` file explicitly with pytest; normal ``test_*.py`` discovery +excludes it. Unsafe alias assertions intentionally fail until lexical authority is +implemented or the unsupported use is rejected. No C++/QML code executes. Unknown +targets are acceptable; an unrelated same-spelling global class is never proof. +""" +from __future__ import annotations + +import json + +import pytest + +from graphify.affected import load_graph +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import extract +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_qml_bridge import build_qt_qml_bridge + +DECLARATIONS = """class Sender : public QObject { Q_OBJECT +signals: void changed(int value); +}; +class Receiver : public QObject { Q_OBJECT +public: void accept(int value) {} +}; +class Other : public QObject { Q_OBJECT +signals: void changed(int value); +}; +""" +ALIASES = ["using Sender = Other;", "typedef Other Sender;"] +QML = "import Demo 1.0\nThing { onChanged: (value) => {} }\n" + + +def persisted(root, source, *, qml=False): + """Synthetic bytes enter the real facade, assembly, JSON writer and loader.""" + path = root / "events.cpp" + path.write_bytes(source.encode("utf-8")) + paths = [path] + if qml: + qml_path = root / "Main.qml" + qml_path.write_bytes(QML.encode("utf-8")) + paths.append(qml_path) + result = extract(paths, root=root, cache_root=root / "cache", parallel=False, + qml_import_roots=(".",)) + assert not result.get("qml_failures") and not result.get("qt_failures") + output = root / "proof.json" + graph = build_from_json(result, root=root) + assert to_json(graph, {}, str(output), force=True) + payload = json.loads(output.read_text(encoding="utf-8")) + reloaded = load_graph(output) + assert reloaded.is_directed() + # Evidence belongs to original file bytes, not normalized source/graph labels. + for node in payload["nodes"]: + metadata = qt_metadata(node) or qml_metadata(node) + if not metadata or not metadata.get("span"): + continue + data = (root / node["source_file"]).read_bytes() + span = metadata["span"] + start, end = span["start_byte"], span["end_byte"] + assert 0 <= start <= end <= len(data) + for prefix, offset in (("start", start), ("end", end)): + preceding = data[:offset] + assert span[prefix + "_row"] == preceding.count(b"\n") + assert span[prefix + "_column"] == len(preceding.rsplit(b"\n", 1)[-1]) + return result, payload, reloaded + + +def native_facts(result, kind): + return [node for node in result["nodes"] if qt_metadata(node).get("kind") == kind] + + +def native_signal(result, class_name): + return next(node for node in native_facts(result, "member") + if qt_metadata(node).get("class_name") == class_name + and qt_metadata(node).get("raw_name") == "changed") + + +def endpoint_links(payload, site): + """Role nodes retain logical direction after default undirected assembly.""" + role_ids = {node["id"] for node in payload["nodes"] + if qt_metadata(node).get("kind") == "event_endpoint" + and qt_metadata(node).get("owner_id") == site["id"]} + return [edge for edge in payload["links"] + if edge["source"] in role_ids | {site["id"]} + and edge.get("context") in {"qt_connect_signal", "qt_signal_emit"}] + + +def assert_no_wrong_signal(result, payload, graph, site): + """Unsupported aliases may remain unresolved; wrong persisted edges may not.""" + wrong = native_signal(result, "Sender") + forbidden = {wrong["id"], qt_metadata(wrong)["generic_target_id"]} + links = endpoint_links(payload, site) + assert not [edge for edge in links if edge["target"] in forbidden], links + for edge in links: + assert graph.has_edge(edge["source"], edge["target"]) + metadata = qt_metadata(site) + assert metadata.get("signal_target_id") not in forbidden, metadata + assert metadata.get("target_id") not in forbidden, metadata + + +@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"]) +def test_req_qml016_connect_alias_cannot_select_unrelated_global_signal(tmp_path, alias): + """A local alias shadows Sender; connect must not persist global Sender's signal.""" + source = DECLARATIONS + f"""void wire(Other *original, Receiver *receiver) {{ + {alias} + Sender *sender = original; + QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept); +}} +""" + result, payload, graph = persisted(tmp_path, source) + site = native_facts(result, "connect")[0] + assert_no_wrong_signal(result, payload, graph, site) + + +@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"]) +def test_req_qml016_emission_alias_cannot_select_unrelated_global_signal(tmp_path, alias): + """An explicit alias-typed receiver supplies no authority for the global class.""" + source = DECLARATIONS + f"""void send(Other *original) {{ + {alias} + Sender *sender = original; + emit sender->changed(1); +}} +""" + result, payload, graph = persisted(tmp_path, source) + assert_no_wrong_signal(result, payload, graph, native_facts(result, "emission")[0]) + + +def registration_proof(root, alias, raw_type="Sender"): + source = DECLARATIONS + f"""void install() {{ + {alias} + qmlRegisterType<{raw_type}>("Demo", 1, 0, "Thing"); +}} +""" + result, payload, graph = persisted(root, source, qml=True) + bridge = build_qt_qml_bridge(result["nodes"], result["edges"], root=root) + provider = bridge.module_type("Demo", 1, 0, "Thing") + handlers = [node for node in payload["nodes"] if qml_metadata(node).get("kind") == "handler"] + assert len(handlers) == 1 + return result, payload, graph, bridge, provider, handlers[0] + + +@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"]) +def test_req_qml008_alias_registration_cannot_export_global_class_to_qml(tmp_path, alias): + """Completed native registration→QML handler edges must honor type authority.""" + result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, alias) + wrong = native_signal(result, "Sender") + forbidden = {wrong["id"], qt_metadata(wrong)["generic_target_id"]} + links = [edge for edge in payload["links"] if edge["source"] == handler["id"] + and edge.get("context") == "qml_signal_subscription"] + assert not [edge for edge in links if edge["target"] in forbidden], links + assert qml_metadata(handler).get("resolved_target_id") not in forbidden + wrong_class = qt_metadata(wrong)["class_id"] + assert qt_metadata(native_facts(result, "registration")[0]).get("class_id") != wrong_class + if provider.target_id: + assert bridge.provider_records[provider.target_id]["class_id"] != wrong_class + for edge in links: + assert graph.has_edge(edge["source"], edge["target"]) + + +def test_req_qml016_block_alias_does_not_change_before_and_after_native_scope(tmp_path): + """The alias applies inside its block; globally suppressing Sender is insufficient.""" + source = DECLARATIONS + """void wire(Sender *outer, Other *other, Receiver *receiver) { + QObject::connect(outer, &Sender::changed, receiver, &Receiver::accept); + { using Sender = Other; Sender *inner = other; + QObject::connect(inner, &Sender::changed, receiver, &Receiver::accept); } + QObject::connect(outer, &Sender::changed, receiver, &Receiver::accept); +} +""" + result, payload, graph = persisted(tmp_path, source) + before, inside, after = native_facts(result, "connect") + target = native_signal(result, "Sender")["id"] + assert qt_metadata(before)["signal_target_id"] == target + assert qt_metadata(after)["signal_target_id"] == target + assert {edge["target"] for edge in endpoint_links(payload, before)} == {target} + assert {edge["target"] for edge in endpoint_links(payload, after)} == {target} + assert_no_wrong_signal(result, payload, graph, inside) + + +@pytest.mark.parametrize("class_name", ["Sender", "Other"]) +def test_direct_native_type_control_preserves_roles_and_direction(tmp_path, class_name): + """Unshadowed connections/emissions retain distinct roles and logical direction.""" + source = DECLARATIONS + f"""void wire({class_name} *sender, Receiver *receiver) {{ + QObject::connect(sender, &{class_name}::changed, receiver, &Receiver::accept); + emit sender->changed(1); +}} +""" + result, payload, graph = persisted(tmp_path, source) + connection = native_facts(result, "connect")[0] + emission = native_facts(result, "emission")[0] + signal = native_signal(result, class_name)["id"] + assert qt_metadata(connection)["status"] == qt_metadata(emission)["status"] == "resolved" + for site in (connection, emission): + links = endpoint_links(payload, site) + assert len(links) == 1 and links[0]["target"] == signal + assert graph.has_edge(links[0]["source"], signal) + assert not graph.has_edge(signal, links[0]["source"]) + roles = [node for node in payload["nodes"] + if qt_metadata(node).get("kind") == "event_endpoint" + and qt_metadata(node).get("owner_id") == connection["id"]] + assert {qt_metadata(node)["role"] for node in roles} == {"signal", "receiver"} + assert len({node["id"] for node in roles}) == 2 + assert not any(edge["source"] == connection["id"] and edge.get("relation") == "calls" + for edge in payload["links"]) + + +@pytest.mark.parametrize("class_name", ["Sender", "Other"]) +def test_direct_registration_control_preserves_native_qml_endpoint(tmp_path, class_name): + """A literal unshadowed registration exports only its actual native signal.""" + result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, "", class_name) + signal = native_signal(result, class_name) + assert provider.status == "resolved" + assert bridge.provider_records[provider.target_id]["class_id"] == qt_metadata(signal)["class_id"] + target = qt_metadata(signal)["generic_target_id"] + assert qml_metadata(handler)["status"] == "resolved" + assert qml_metadata(handler)["resolved_target_id"] == target + links = [edge for edge in payload["links"] if edge["source"] == handler["id"] + and edge.get("context") == "qml_signal_subscription"] + assert len(links) == 1 and links[0]["target"] == target + assert graph.has_edge(handler["id"], target) and not graph.has_edge(target, handler["id"]) diff --git a/tests/audit/probe_qt_qml_object_boundaries.py b/tests/audit/probe_qt_qml_object_boundaries.py new file mode 100644 index 0000000000..107797087d --- /dev/null +++ b/tests/audit/probe_qt_qml_object_boundaries.py @@ -0,0 +1,139 @@ +"""Opt-in public audit of receiver identity; expected rejection assertions stay strict. + +Invoke this probe explicitly with pytest. Its name excludes normal ``test_*.py`` +discovery. Synthetic sources pass through production extraction, graph assembly +and JSON reload; neither QML nor a Qt application/build hook executes. +""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites + +BACKEND = '''class Backend : public QObject { + Q_OBJECT + Q_PROPERTY(int count READ count) +public: + int count() { return 1; } +}; +''' +QML = '''import QtQuick +Item { + property int rootOnly: 7 + function rootAction() {} + Item { objectName: "left"; property int localValue: 1; Item { objectName: "deep" } } + Item { objectName: "right"; property int localValue: 2 } +} +''' + + +def persisted(root, sources): + """Read only synthetic inputs and inspect the actual completed JSON output.""" + result = analysis(root, sources) + graph = build_from_json(result, root=root) + output = root / "proof.json" + assert to_json(graph, {}, str(output), force=True) + return result, load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + + +def access_source(root, tail): + """The literal file URL establishes a receiver without runtime execution.""" + return f'''void use() {{ + QQuickView view; + view.setSource(QUrl("{(root / 'Main.qml').as_uri()}")); + auto root = view.rootObject(); + auto left = root->findChild("left"); + {tail} +}}''' + + +def target_edges(graph, site, contexts): + """Select semantic target links by their serialized logical source direction.""" + return [(data.get("_tgt", target), data.get("relation"), data.get("context")) + for source, target, data in graph.edges(site["id"], data=True) + if data.get("_src", source) == site["id"] and data.get("context") in contexts] + + +@pytest.mark.parametrize("tail", ['left->property("rootOnly");', 'left->setProperty("rootOnly", 9);', + 'QMetaObject::invokeMethod(left, "rootAction");']) +def test_cpp_reflective_child_property_does_not_inherit_qml_lexical_root(tmp_path, tail): + """REQ-QML-017-AC02/AC04: a child must not acquire its QML root's reflected API.""" + result, graph = persisted(tmp_path, {"access.cpp": access_source(tmp_path, tail), "Main.qml": QML}) + access = sites(result, "qml_access")[-1] + span = qt_metadata(access)["span"] + assert (tmp_path / "access.cpp").read_bytes()[span["start_byte"]:span["end_byte"]].decode() == tail[:-1] + assert not target_edges(graph, access, {"qt_cpp_qml_property_read", "qt_cpp_qml_property_write", "qt_cpp_qml_invoke"}), ( + "The child's read/write/invoke site persisted an enclosing-root member edge") + assert qt_metadata(access)["status"] != "resolved" + + +def test_cpp_findchild_does_not_search_sibling_outside_receiver_subtree(tmp_path): + """REQ-QML-017-AC02/AC04: left has no right child; a sibling is not its target.""" + result, graph = persisted(tmp_path, {"access.cpp": access_source(tmp_path, 'left->findChild("right");'), + "Main.qml": QML}) + access = sites(result, "qml_access")[-1] + assert not target_edges(graph, access, {"qt_cpp_qml_find_child"}), "A sibling target edge survived build/JSON reload" + assert qt_metadata(access)["status"] != "resolved" + + +def test_cpp_findchild_direct_search_cannot_select_grandchild(tmp_path): + """REQ-QML-017-AC02/AC04: direct-child search must not choose nested deep.""" + result, graph = persisted(tmp_path, {"access.cpp": access_source(tmp_path, + 'root->findChild("deep", Qt::FindDirectChildrenOnly);'), + "Main.qml": QML}) + access = sites(result, "qml_access")[-1] + assert not target_edges(graph, access, {"qt_cpp_qml_find_child"}), "Direct-child search persisted a grandchild edge" + assert qt_metadata(access)["status"] != "resolved" + + +def test_disjoint_engine_scopes_cannot_supply_context_provider(tmp_path): + """REQ-QML-017-AC03/AC04: a destroyed engine cannot inject into a new engine.""" + source = BACKEND + f'''void use(Backend *backend) {{ + {{ QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("backend", backend); + }} + QQmlApplicationEngine engine; + engine.load(QUrl("{(tmp_path / 'Main.qml').as_uri()}")); +}}''' + result, graph = persisted(tmp_path, {"access.cpp": source, + "Main.qml": 'import QtQml\nQtObject { property int value: backend.count }'}) + joins = sites(result, "context_access") + assert not [(node["id"], edge) for node in joins for edge in target_edges(graph, node, {"qt_context_member"})], ( + "A block-local engine's provider edge reached another engine's loaded component") + assert not joins + + +def test_public_receiver_and_engine_positive_controls(tmp_path): + """Valid own-receiver lookups and same-engine context joins remain resolvable.""" + source = access_source(tmp_path, 'left->property("localValue"); root->property("rootOnly"); root->findChild("right");') + result, graph = persisted(tmp_path, {"access.cpp": source, "Main.qml": QML}) + accesses = sites(result, "qml_access") + assert len(accesses) == 4 and all(qt_metadata(node)["status"] == "resolved" for node in accesses) + assert all(graph.has_edge(node["id"], qt_metadata(node)["target_id"]) for node in accesses) + folder = tmp_path / "context" + folder.mkdir() + source = BACKEND + f'''void use(Backend *backend) {{ QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("backend", backend); + engine.load(QUrl("{(folder / 'Main.qml').as_uri()}")); }}''' + result, graph = persisted(folder, {"access.cpp": source, + "Main.qml": 'import QtQml\nQtObject { property int value: backend.count }'}) + joins = sites(result, "context_access") + assert len(joins) == 1 and graph.has_edge(joins[0]["id"], qt_metadata(joins[0])["target_id"]) + + +def test_pending_typed_factory_provider_remains_unresolved(tmp_path): + """REQ-QML-018-AC03 remains a planned gap; unavailable factory types are not guessed.""" + source = BACKEND + f'''class Factory {{ public: Backend *makeBackend(); }}; +void use(Factory *factory) {{ QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("backend", factory->makeBackend()); + engine.load(QUrl("{(tmp_path / 'Main.qml').as_uri()}")); }}''' + result, _ = persisted(tmp_path, {"access.cpp": source, + "Main.qml": 'import QtQml\nQtObject { property int value: backend.count }'}) + assert not sites(result, "context_binding") and not sites(result, "context_access") + assert qt_metadata(sites(result, "context_exposure")[0])["status"] == "unavailable" diff --git a/tests/audit/probe_qt_reflection_type_shadowing.py b/tests/audit/probe_qt_reflection_type_shadowing.py new file mode 100644 index 0000000000..cb0bb4da77 --- /dev/null +++ b/tests/audit/probe_qt_reflection_type_shadowing.py @@ -0,0 +1,112 @@ +"""Opt-in INC-QML-22 reflection identity audit; rejection assertions stay strict. + +Explicit pytest invocation is required: ``probe_*.py`` is outside normal discovery. +Public synthetic inputs reach extraction, build, JSON publication and reload. No +Qt compiler, application, QML component or corpus build hook executes. The source +class controls describe accepted parser inputs, not claims of successful Qt builds. +""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qml_resolution_types import qml_metadata +from tests.qt_analysis_helpers import analysis, sites + +QML = 'import QtQml\nQtObject { property int count: 1; function refresh() {} }' +OPERATIONS = { + "invoke": ('static void invokeMethod(QObject *, const char *) {}', + 'QMetaObject::invokeMethod(root, "refresh");', "QMetaObject"), + "static_read": ('static QVariant read(QObject *, const char *) {}', + 'QQmlProperty::read(root, "count");', "QQmlProperty"), + "static_write": ('static bool write(QObject *, const char *, QVariant) {}', + 'QQmlProperty::write(root, "count", 2);', "QQmlProperty"), + "property_handle": ('OtherProperty(QObject *, const char *) {} QVariant read() {}', + 'QQmlProperty handle(root, "count"); handle.read();', "QQmlProperty"), +} +TARGET_CONTEXTS = {"qt_cpp_qml_invoke", "qt_cpp_qml_property_read", "qt_cpp_qml_property_write"} + + +def source(root, operation, shadow): + """A real source loader supplies the receiver; the reflection type varies.""" + member, tail, sdk_name = OPERATIONS[operation] + definition, alias, namespace = "", "", "" + if shadow == "local_alias": + other = "OtherMeta" if sdk_name == "QMetaObject" else "OtherProperty" + definition = f"struct {other} {{ {member} }};" + alias = f"using {sdk_name} = {other};" + elif shadow in {"global_source_class", "namespace_source_class"}: + definition = f"struct {sdk_name} {{ {member.replace('OtherProperty(', 'QQmlProperty(')} }};" + namespace = "Public" if shadow == "namespace_source_class" else "" + elif shadow == "sdk_namespace_control": + namespace = "Public" + elif shadow != "sdk_control": + raise ValueError(shadow) + body = f'''// Original-byte evidence: café +{definition} +void use() {{ QQuickView view; + view.setSource(QUrl("{(root / 'Main.qml').as_uri()}")); + auto root = view.rootObject(); + {alias} + {tail} +}}''' + return (("namespace " + namespace + " {\n" + body + "\n}") if namespace else body).replace("\n", "\r\n") + + +def persisted(root, operation, shadow): + """Check actual durable direction and original source evidence before rejection.""" + original = source(root, operation, shadow) + result = analysis(root, {"access.cpp": original, "Main.qml": QML}) + graph = build_from_json(result, root=root) + output = root / "proof.json" + assert to_json(graph, {}, str(output), force=True) + payload = json.loads(output.read_text(encoding="utf-8")) + restored = load_node_link_graph(payload) + accesses = sites(result, "qml_access") + by_id = {node["id"]: node for node in accesses} + for node in accesses: + metadata = qt_metadata(node) + span = metadata["span"] + text = original.encode()[span["start_byte"]:span["end_byte"]].decode() + assert text and text in OPERATIONS[operation][1] + assert node["source_file"] == "access.cpp" + assert node["source_location"].startswith("L") + links = [edge for edge in payload["links"] if edge["source"] in by_id + and edge.get("context") in TARGET_CONTEXTS] + for edge in links: + site = by_id[edge["source"]] + assert restored.has_edge(edge["source"], edge["target"]) + actual = restored.edges[edge["source"], edge["target"]] + assert (actual["_src"], actual["_tgt"]) == (edge["source"], edge["target"]) + assert actual["relation"] == ("calls" if operation == "invoke" else "uses") + assert actual["confidence"] == "INFERRED" + assert actual["source_file"] == "access.cpp" + assert actual["source_location"] == site["source_location"] + assert qt_metadata(actual)["span"] == qt_metadata(site)["span"] + return result, accesses, links + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +@pytest.mark.parametrize("shadow", ["local_alias", "global_source_class", "namespace_source_class"]) +def test_req_qml017_ac02_shadowed_reflection_type_cannot_create_qml_target(tmp_path, operation, shadow): + """A local alias or source class spelling is not the Qt reflection SDK class.""" + _, _, links = persisted(tmp_path, operation, shadow) + assert not links, "Unrelated reflection class persisted a false QML member target" + + +@pytest.mark.parametrize("operation, shadow", [ + ("invoke", "sdk_namespace_control"), ("property_handle", "sdk_control"), +]) +def test_req_qml017_ac04_unshadowed_sdk_reflection_preserves_targets_and_provenance(tmp_path, operation, shadow): + """The correction must preserve genuine SDK calls, including namespace scope.""" + result, accesses, links = persisted(tmp_path, operation, shadow) + assert len(links) == (1 if operation == "invoke" else 2) + assert accesses and all(qt_metadata(node)["status"] == "resolved" for node in accesses) + nodes = {node["id"]: node for node in result["nodes"]} + assert {qml_metadata(nodes[edge["target"]])["raw_name"] for edge in links} == { + "refresh" if operation == "invoke" else "count"} diff --git a/tests/fixtures/qml/parser_probe/Empty.qml b/tests/fixtures/qml/parser_probe/Empty.qml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/fixtures/qml/parser_probe/Grouped.qml b/tests/fixtures/qml/parser_probe/Grouped.qml new file mode 100644 index 0000000000..0d404474ee --- /dev/null +++ b/tests/fixtures/qml/parser_probe/Grouped.qml @@ -0,0 +1,8 @@ +// Lowercase grouped properties must not become instantiated component types. +import QtQuick +Text { + id: label + anchors { left: parent.left; topMargin: 4 } + font { pixelSize: 12; bold: true } + text: "Grouped properties" +} diff --git a/tests/fixtures/qml/parser_probe/Imports.qml b/tests/fixtures/qml/parser_probe/Imports.qml new file mode 100644 index 0000000000..23e672f3ec --- /dev/null +++ b/tests/fixtures/qml/parser_probe/Imports.qml @@ -0,0 +1,6 @@ +// Major-only, full-version, versionless and local import forms stay distinct. +import QtQuick 6 +import Example.Widgets 1.2 +import Example.Services as Services +import "./components" +Item {} diff --git a/tests/fixtures/qml/parser_probe/Incomplete.qml b/tests/fixtures/qml/parser_probe/Incomplete.qml new file mode 100644 index 0000000000..69ca0997da --- /dev/null +++ b/tests/fixtures/qml/parser_probe/Incomplete.qml @@ -0,0 +1,7 @@ +// Deliberately unfinished editor buffer; preserve earlier declaration ranges. +import QtQuick +Item { + id: root + property int retained: 7 + function unfinished() { + return retained + diff --git a/tests/fixtures/qml/parser_probe/Main.qml b/tests/fixtures/qml/parser_probe/Main.qml new file mode 100644 index 0000000000..0ff60d0411 --- /dev/null +++ b/tests/fixtures/qml/parser_probe/Main.qml @@ -0,0 +1,34 @@ +// Synthetic Qt 6.5 syntax profile; parsing does not validate runtime types. +import QtQuick +import QtQuick.Controls 6.5 as Controls +import "./components" as Local +import "helpers.js" as Helpers + +Item { + id: root + required property string title + readonly property int doubled: count * 2 + default property list extras + property int count: 1 + property alias editorText: editor.text + signal activated(int value, string label) + signal changed(value: int) + + function bump(step: int): int { + const total = count + step; + count = total; + activated(total, title); + return total; + } + + onCountChanged: { + const message = "punctuation: {}; //"; + if (count > 0) { bump(1); } + } + + Controls.TextField { + id: editor + text: root.title + onTextChanged: root.count = text.length + } +} diff --git a/tests/fixtures/qml/parser_probe/Malformed.qml b/tests/fixtures/qml/parser_probe/Malformed.qml new file mode 100644 index 0000000000..f8e3d0edc7 --- /dev/null +++ b/tests/fixtures/qml/parser_probe/Malformed.qml @@ -0,0 +1,6 @@ +// Invalid tokens are deliberate; the later property should remain recoverable. +import QtQuick +Item { + property int broken: @@@; + property string retained: "safe" +} diff --git a/tests/fixtures/qml/parser_probe/Modern68.qml b/tests/fixtures/qml/parser_probe/Modern68.qml new file mode 100644 index 0000000000..9507aaf416 --- /dev/null +++ b/tests/fixtures/qml/parser_probe/Modern68.qml @@ -0,0 +1,16 @@ +// Synthetic Qt 6.8 syntax profile; this is not a running Qt application. +pragma ComponentBehavior: Bound +import QtQuick 6.8 + +Item { + id: outer + enum Mode { Idle = 0, Active = 1 } + component Tile: Rectangle { + id: tile + required property int value + property alias ownValue: tile.value + } + Tile { value: 3 } + property var mapper: (value) => value + 1 + Component.onCompleted: mapper(Mode.Active) +} diff --git a/tests/fixtures/qml/parser_probe/Script.qml b/tests/fixtures/qml/parser_probe/Script.qml new file mode 100644 index 0000000000..73c318f132 --- /dev/null +++ b/tests/fixtures/qml/parser_probe/Script.qml @@ -0,0 +1,16 @@ +// Comments and strings deliberately imitate QML; the parser must keep JS scopes. +import QtQuick +Item { + property var transform: (value) => ({ label: "}", value: value + 1 }) + function evaluate(input) { + let value = input; + { let value = 3; value += 1; } + const outside = value; + return transform(outside).value; + } + Component.onCompleted: { + const literal = "signal fake(int x) { import Broken }"; + /* A brace } and fake declaration: signal phantom() */ + evaluate(2); + } +} diff --git a/tests/fixtures/qml/parser_probe/UnicodeCRLF.qml b/tests/fixtures/qml/parser_probe/UnicodeCRLF.qml new file mode 100644 index 0000000000..be5aecc082 --- /dev/null +++ b/tests/fixtures/qml/parser_probe/UnicodeCRLF.qml @@ -0,0 +1,7 @@ +// UTF-8 and CRLF are intentional; columns count bytes, not Unicode characters. +import QtQuick +Item { + id: root + property string café: "雪😀"; property int après: 2 + function résumé(étape) { return café + étape; } +} diff --git a/tests/fixtures/qml/parser_probe/plugins.qmltypes b/tests/fixtures/qml/parser_probe/plugins.qmltypes new file mode 100644 index 0000000000..e38b96c2c3 --- /dev/null +++ b/tests/fixtures/qml/parser_probe/plugins.qmltypes @@ -0,0 +1,12 @@ +// Synthetic type-description syntax, not metadata produced by a private project. +import QtQuick.tooling 1.2 +Module { + Component { + name: "Example::Backend" + prototype: "QObject" + exports: ["Example/Backend 1.0"] + Property { name: "value"; type: "int"; isReadonly: true } + Method { name: "reset" } + Signal { name: "updated"; Parameter { name: "value"; type: "int" } } + } +} diff --git a/tests/fixtures/qml/parser_probe/qmldir b/tests/fixtures/qml/parser_probe/qmldir new file mode 100644 index 0000000000..cc856a22c8 --- /dev/null +++ b/tests/fixtures/qml/parser_probe/qmldir @@ -0,0 +1,9 @@ +# Synthetic basic module descriptor. +module Example.Widgets +Main 1.0 Main.qml +singleton Theme 1.0 Theme.qml +internal Helper Helper.qml +typeinfo plugins.qmltypes +plugin exampleplugin +classname ExamplePlugin +depends QtQuick.Controls 6.5 diff --git a/tests/fixtures/qml/parser_probe/qmldir.modern b/tests/fixtures/qml/parser_probe/qmldir.modern new file mode 100644 index 0000000000..f50e6ecdb5 --- /dev/null +++ b/tests/fixtures/qml/parser_probe/qmldir.modern @@ -0,0 +1,6 @@ +# Qt 6 metadata forms require their own syntax coverage decision. +module Example.Widgets +optional plugin exampleplugin +import QtQuick auto +prefer :/qt/qml/Example/Widgets/ +Main Main.qml diff --git a/tests/fixtures/qml/project_metadata/CMakeLists.txt b/tests/fixtures/qml/project_metadata/CMakeLists.txt new file mode 100644 index 0000000000..d218fa6538 --- /dev/null +++ b/tests/fixtures/qml/project_metadata/CMakeLists.txt @@ -0,0 +1,12 @@ +# Literal Qt 6 module fixture; analysis must never configure this project. +cmake_minimum_required(VERSION 3.21) +project(PublicTools LANGUAGES CXX) +find_package(Qt6 REQUIRED COMPONENTS Qml Quick) +qt_add_qml_module(tools + URI Public.Tools + VERSION 1.0 + RESOURCE_PREFIX /ui + QML_FILES Main.qml + SOURCES backend.h + IMPORTS QtQuick/2.15 +) diff --git a/tests/fixtures/qml/project_metadata/Main.qml b/tests/fixtures/qml/project_metadata/Main.qml new file mode 100644 index 0000000000..dff94408f2 --- /dev/null +++ b/tests/fixtures/qml/project_metadata/Main.qml @@ -0,0 +1,10 @@ +import QtQuick 2.15 +import Public.Tools 1.0 + +Item { + id: root + objectName: "publicRoot" + property int status: service.status + Service { id: service } + function refresh() { service.refresh() } +} diff --git a/tests/fixtures/qml/project_metadata/backend.h b/tests/fixtures/qml/project_metadata/backend.h new file mode 100644 index 0000000000..353fa80972 --- /dev/null +++ b/tests/fixtures/qml/project_metadata/backend.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include + +class Backend : public QObject { + Q_OBJECT + QML_NAMED_ELEMENT(Service) + Q_PROPERTY(int status READ status NOTIFY statusChanged) +public: + int status() const { return 1; } + Q_INVOKABLE void refresh() { emit statusChanged(); } +signals: + void statusChanged(); +}; diff --git a/tests/fixtures/qml/project_metadata/resources.qrc b/tests/fixtures/qml/project_metadata/resources.qrc new file mode 100644 index 0000000000..b5e947a092 --- /dev/null +++ b/tests/fixtures/qml/project_metadata/resources.qrc @@ -0,0 +1,5 @@ + + + Main.qml + + diff --git a/tests/fixtures/qml/project_metadata/tools.pro b/tests/fixtures/qml/project_metadata/tools.pro new file mode 100644 index 0000000000..616c468e1f --- /dev/null +++ b/tests/fixtures/qml/project_metadata/tools.pro @@ -0,0 +1,9 @@ +# Equivalent literal Qt 6 qmake module context. +TEMPLATE = lib +TARGET = tools +QT += qml quick +CONFIG += qmltypes +QML_IMPORT_NAME = Public.Tools +QML_IMPORT_VERSION = 1.0 +HEADERS += backend.h +RESOURCES += resources.qrc diff --git a/tests/qml_ci.py b/tests/qml_ci.py new file mode 100644 index 0000000000..a85ed0add3 --- /dev/null +++ b/tests/qml_ci.py @@ -0,0 +1,48 @@ +"""Verify optional QML wheels in clean environments on each CI host/Python lane.""" +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import tempfile + + +def run(*args, **kwargs): + subprocess.run(list(args), check=True, **kwargs) + + +def interpreter(environment): + return str(environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")) + + +def main(): + root = Path(__file__).resolve().parent.parent + workspace = root / ".venv/qml-ci" + wheel_directory = workspace / "wheel" + run("uv", "build", "--wheel", "--python", sys.executable, "--out-dir", str(wheel_directory), cwd=root) + wheels = list(wheel_directory.glob("*.whl")) + assert len(wheels) == 1, "Expected exactly one reviewed Graphify wheel" + wheel = wheels[0] + extra, core = workspace / "extra", workspace / "core" + for environment in (extra, core): + run("uv", "venv", "--python", sys.executable, str(environment)) + run("uv", "pip", "install", "--python", interpreter(extra), str(wheel) + "[qml,watch]", + "pytest>=8,<10", "packaging") + run("uv", "pip", "install", "--python", interpreter(core), str(wheel)) + smoke = str(root / "tests/qml_installed_smoke.py") + with tempfile.TemporaryDirectory() as neutral: + run(interpreter(extra), "-I", smoke, cwd=neutral) + run(interpreter(core), "-I", smoke, "--core-only", cwd=neutral) + environment = dict(os.environ, GRAPHIFY_QML_TEST_WHEEL=str(wheel), PYTHONUTF8="1") + # Joint upstream/Qt boundaries belong to the same native source profile; + # Linux's all-extras job owns them while this lane keeps isolated smokes. + tests = (["tests/test_qml_wheel_artifact.py"] if "--artifact-only" in sys.argv + else sorted(str(path.relative_to(root)) for path in (root / "tests").glob("test_*.py") + if path.name.startswith(("test_qml_", "test_qt_", "test_upstream_qt_")))) + run(interpreter(extra), "-X", "utf8", "-m", "pytest", *tests, "-q", "-ra", "--tb=short", + cwd=root, env=environment) + + +if __name__ == "__main__": + main() diff --git a/tests/qml_expression_helpers.py b/tests/qml_expression_helpers.py new file mode 100644 index 0000000000..82afca3cc3 --- /dev/null +++ b/tests/qml_expression_helpers.py @@ -0,0 +1,33 @@ +"""Production extractor fixtures for scoped QML/JS expression acceptance.""" + +from graphify.extract import extract +from graphify.extractors.qml_facts import qml_metadata + + +def extraction(root, sources): + paths = [] + for name, source in sources.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + paths.append(path) + result = extract(paths, root=root, cache_root=root, parallel=False) + assert not result.get("qml_failures"), result.get("qml_failures") + return result + + +def nodes(result, kind, name=None, file=None): + return [node for node in result["nodes"] if qml_metadata(node).get("kind") == kind + and (name is None or qml_metadata(node).get("raw_name") == name) + and (file is None or node["source_file"] == file)] + + +def single(result, kind, name=None, file=None): + found = nodes(result, kind, name, file) + assert len(found) == 1, (kind, name, file, found) + return found[0] + + +def target_edges(result, site, context=None): + return [edge for edge in result["edges"] if edge["source"] == site["id"] + and edge["relation"] != "contains" and (context is None or edge.get("context") == context)] diff --git a/tests/qml_installed_smoke.py b/tests/qml_installed_smoke.py new file mode 100644 index 0000000000..29e76468c0 --- /dev/null +++ b/tests/qml_installed_smoke.py @@ -0,0 +1,127 @@ +"""Run with an isolated interpreter to verify a clean installed Graphify wheel. + +This intentionally uses no pytest/source-checkout imports. CI supplies either +the qml extra or --core-only, then checks an actual production entry point. +""" +from __future__ import annotations + +import importlib.metadata +import json +from pathlib import Path +import sys +import tempfile + + +def offline_guard(event, _args): + if event in {"socket.connect", "socket.getaddrinfo", "subprocess.Popen", "os.system"}: + raise RuntimeError("Analysis attempted a network/process operation") + + +def native_module_smoke(root): + """Installed facade joins accepted build membership and literal resources.""" + from graphify.build import build_from_json + from graphify.extract import extract + from graphify.extractors.qml_facts import qml_metadata + from graphify.extractors.qt_cpp_facts import qt_metadata + + root.mkdir() + files = { + "backend.h": 'class Backend : public QObject { Q_OBJECT QML_ELEMENT ' + 'Q_PROPERTY(int count READ count) public: int count() { return 1; } ' + 'Q_INVOKABLE int next(int value) { return value; } };', + "Main.qml": 'import Installed.Tools 1.0\nBackend { property int copied: count; ' + 'function run() { next(1) } }', + "loader.cpp": 'void load(){ QQmlApplicationEngine engine; ' + 'engine.load(QUrl("qrc:/ui/Main.qml")); }', + "CMakeLists.txt": 'qt_add_qml_module(installed URI Installed.Tools VERSION 1.0 ' + 'QML_FILES Main.qml SOURCES backend.h loader.cpp)', + "resources.qrc": '' + 'Main.qml', + } + paths = [] + for name, source in files.items(): + path = root / name + path.write_text(source, encoding="utf-8") + paths.append(path) + result = extract(paths, root=root, cache_root=root, parallel=False) + assert not result["failed_sources"] and not result["qml_failures"] + graph = build_from_json(result, root=root, directed=True) + call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call") + proof = qt_metadata(call)["native_endpoint"] + assert proof["canonical_target_id"] == call["target"] + assert graph.nodes[call["target"]]["source_file"] == "backend.h" + assert graph.nodes[call["target"]]["label"].endswith(".next()") + assert graph.has_edge(call["source"], call["target"]) + assert qt_metadata(graph.nodes[proof["provider_id"]])["kind"] == "registration" + modules = [graph.nodes[item] for item in proof["evidence"] + if qml_metadata(graph.nodes[item]).get("kind") == "qt_module"] + assert len(modules) == 1 and modules[0]["source_file"] == "CMakeLists.txt" + assert qml_metadata(modules[0])["uri"] == "Installed.Tools" + loader = next(node for node in result["nodes"] if qt_metadata(node).get("kind") == "qml_load") + component = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "component") + assert qt_metadata(loader)["status"] == "resolved" + assert qt_metadata(loader)["target_id"] == component["id"] + edge = graph.edges[loader["id"], component["id"]] + assert edge["context"] == "qt_cpp_qml_load" and edge["source_file"] == "loader.cpp" + + +def main(): + sys.addaudithook(offline_guard) + from graphify.extract import extract, extract_python + from graphify.extractors.qml import extract_qml + from graphify.extractors.qml_metadata import extract_qmldir + from graphify.extractors.qml_facts import qml_metadata + from graphify.build import build_from_json + from graphify.detect import FileType, classify_file + from graphify.validate import validate_extraction + + assert "site-packages" in (sys.modules["graphify"].__file__ or "") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "Main.qml" + path.write_text('import "helpers.js" as Tools\nItem { id: root; property int value: 3; property int derived: value + Tools.twice(value); signal ready(int eventValue); onReady: { value = Tools.twice(eventValue) } function twice() { return value * 2; } }', encoding="utf-8") + assert classify_file(path) == FileType.CODE + metadata = root / "qmldir" + original = b'\xef\xbb\xbfmodule Sample.Tools\r\nThing 1.0 Thing.qml\r\n' + metadata.write_bytes(original) + declarations = extract_qmldir(metadata, root=root) + module = next(node for node in declarations["nodes"] if qml_metadata(node)["kind"] == "module") + assert qml_metadata(module)["span"]["start_byte"] == 3 + assert qml_metadata(module)["span"]["end_byte"] == original.index(b'\r\n') + from graphify.extractors.qt_cpp_facts import qt_metadata + native = root / "Backend.cpp" + native.write_text("class Backend : public QObject { Q_OBJECT public: void tick() { emit ready(1); } signals: void ready(int value); };", encoding="utf-8") + cpp = extract([native], root=root, cache_root=root, parallel=False) + assert not cpp["qml_failures"] and not cpp["failed_sources"] + emissions = [node for node in cpp["nodes"] if qt_metadata(node).get("kind") == "emission"] + assert len(emissions) == 1 and qt_metadata(emissions[0])["status"] == "resolved" + assert any(edge.get("context") == "qt_signal_emit" for edge in cpp["edges"]) + result = extract_qml(path, root=root) + if "--core-only" in sys.argv: + assert result["diagnostics"][0]["code"] == "QML_PARSER_MISSING" + assert result["nodes"] == [] + python = root / "main.py" + python.write_text("def safe():\n return 1\n", encoding="utf-8") + assert any(n["label"] == "safe()" for n in extract_python(python, root=root)["nodes"]) + else: + script = root / "helpers.js" + script.write_text('.pragma library\nfunction twice(value) { return value * 2; }', encoding="utf-8") + assert not result.get("error") + declarations = {(n["metadata"]["qml"]["kind"], n["metadata"]["qml"]["raw_name"]) + for n in result["nodes"] if "raw_name" in n["metadata"]["qml"]} + assert {("component", "Main"), ("property", "value"), ("function", "twice")} <= declarations + batch = extract([path, script], root=root, cache_root=root, parallel=False) + assert not batch["failed_sources"] and not batch["qml_failures"] + assert validate_extraction(batch) == [] + graph = build_from_json(batch, directed=True, root=root) + assert graph.number_of_nodes() == len(batch["nodes"]) + assert {"qml_binding_read", "qml_script_call", "qml_signal_subscription"} <= { + edge.get("context") for edge in batch["edges"]} + native_module_smoke(root / "native-profile") + print(json.dumps({"python": sys.version.split()[0], "graphify": importlib.metadata.version("graphifyy"), + "tree_sitter": importlib.metadata.version("tree-sitter"), + "mode": "core-only" if "--core-only" in sys.argv else "qml", "status": "passed"})) + + +if __name__ == "__main__": + main() diff --git a/tests/qml_persistence_helpers.py b/tests/qml_persistence_helpers.py new file mode 100644 index 0000000000..fddc588e81 --- /dev/null +++ b/tests/qml_persistence_helpers.py @@ -0,0 +1,61 @@ +"""Offline parser boundary fakes and real persistence snapshots for QML gates.""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id + + +def healthy_qml(path: Path) -> dict: + """A complete parser contribution with a containment edge to preserve.""" + file_id = _make_id(str(path)) + component_id = _make_id(_file_stem(path), "component") + common = {"source_file": str(path), "file_type": "code", "source_location": "L1"} + return { + "nodes": [ + {**common, "id": file_id, "label": path.name}, + {**common, "id": component_id, "label": "Main"}, + ], + "edges": [{"source": file_id, "target": component_id, "relation": "contains", + "confidence": "EXTRACTED", "source_file": str(path), "source_location": "L1"}], + } + + +def failing_qml(kind: str, *, extra_nodes: int = 0): + """Inject parser failure, including surviving nodes that hide an edge loss.""" + errors = { + "missing": "QML_PARSER_MISSING: tree-sitter-language-pack not installed", + "incompatible": "QML_PARSER_LOAD: QML grammar failed to load", + "read": "QML_SOURCE_READ: source could not be read", + "partial": "QML_PARSE_PARTIAL: syntax recovery lost a declaration", + } + + def extract(path): + result = healthy_qml(path) + result["edges"] = [] + for i in range(extra_nodes): + result["nodes"].append({ + "id": f"replacement_{i}", "label": f"replacement{i}", "file_type": "code", + "source_file": str(path), "source_location": "L1", + }) + result["error"] = errors[kind] + return result + + return extract + + +def make_project(tmp_path: Path) -> Path: + project = tmp_path / "project" + project.mkdir() + (project / "Main.qml").write_text("import QtQuick\nItem { property int count: 1 }\n", encoding="utf-8") + (project / "keep.py").write_text("def retained(): return 1\n", encoding="utf-8") + return project + + +def protected_snapshot(out: Path) -> dict[str, bytes]: + """Measure completed durable products, excluding retryable cache/lock files.""" + return { + p.name: p.read_bytes() for p in out.iterdir() + if p.name in {"graph.json", "manifest.json", "GRAPH_REPORT.md", "graph.html", + ".graphify_root", ".graphify_labels.json", "callflow.html"} + } diff --git a/tests/qml_probe_helpers.py b/tests/qml_probe_helpers.py new file mode 100644 index 0000000000..c5c7ce2d48 --- /dev/null +++ b/tests/qml_probe_helpers.py @@ -0,0 +1,87 @@ +"""Nonshipping QML-00 AST inspection and fresh-process offline probe helpers.""" +from __future__ import annotations + +from collections.abc import Iterator +from typing import overload + +from tree_sitter import Node + + +def walk(root: Node) -> Iterator[Node]: + """Visit every concrete node, including errors and missing syntax tokens.""" + pending = [root] + while pending: + node = pending.pop() + yield node + pending.extend(reversed(node.children)) + + +def nodes(root: Node, kind: str) -> list[Node]: + return [node for node in walk(root) if node.type == kind] + + +@overload +def text(node: Node) -> str: ... + + +@overload +def text(node: None) -> None: ... + + +def text(node: Node | None) -> str | None: + if node is None: + return None + content = node.text + assert content is not None, "Probe nodes retain their original parsed source" + return content.decode("utf-8") + + +def field_text(node: Node, field: str) -> str | None: + return text(node.child_by_field_name(field)) + + +def point_at(source: bytes, offset: int) -> tuple[int, int]: + """Calculate rows and byte columns independently from Tree-sitter nodes.""" + prefix = source[:offset] + return prefix.count(b"\n"), offset - (prefix.rfind(b"\n") + 1) + + +# The child starts with isolated Python, an empty working/cache directory, and an +# audit hook installed before parser imports. This proves Python-network-free +# installed invocation, rather than claiming an OS-wide network sandbox. +OFFLINE_PROBE = r''' +import json +from pathlib import Path +import socket +import sys + +def deny_external_execution(event, args): + if event in {"socket.connect", "socket.getaddrinfo", "subprocess.Popen", "os.system"}: + raise RuntimeError("QML-00 external execution denied") + +sys.addaudithook(deny_external_execution) +try: + socket.create_connection(("127.0.0.1", 9)) +except RuntimeError: + network_denied = True +else: + raise AssertionError("network guard did not reject the control attempt") + +from tree_sitter import Language, Parser +from tree_sitter_language_pack import get_binding, get_parser + +fixtures = Path(sys.argv[1]) +results = {} +for name, filename in (("qmljs", "Main.qml"), ("qmldir", "qmldir")): + parser = Parser(Language(get_binding(name))) + source = (fixtures / filename).read_bytes() + root = parser.parse(source).root_node + results[name] = {"has_error": root.has_error, "end_byte": root.end_byte, "size": len(source)} +try: + get_parser("qml_probe_missing_grammar") +except LookupError: + unavailable = "LookupError" +else: + raise AssertionError("missing grammar unexpectedly succeeded") +print(json.dumps({"network_denied": network_denied, "unavailable": unavailable, "results": results})) +''' diff --git a/tests/qml_resolution_helpers.py b/tests/qml_resolution_helpers.py new file mode 100644 index 0000000000..432146c6c5 --- /dev/null +++ b/tests/qml_resolution_helpers.py @@ -0,0 +1,48 @@ +"""Hand-authored public module fixtures through the actual source extractors.""" + +from graphify.extractors.qml import extract_qml +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qml_metadata import extract_qmldir +from graphify.qml_resolution import build_qml_index + + +def corpus(root, sources): + """Only supplied sources enter the index; its resolver cannot widen discovery.""" + per_file = {} + for relative, text in sources.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + if path.suffix in {".js", ".mjs"}: + from graphify.extract import extract_js + result = extract_js(path) + else: + result = (extract_qmldir(path, root=root) if path.name == "qmldir" else + extract_qml(path, root=root)) + assert not result.get("error"), result + per_file[path] = result + nodes = [node for result in per_file.values() for node in result["nodes"]] + edges = [edge for result in per_file.values() for edge in result["edges"]] + return per_file, nodes, edges + + +def named(nodes, file, kind, name=None): + selected = [node for node in nodes if node["source_file"] == file and + qml_metadata(node).get("kind") == kind and + (name is None or qml_metadata(node).get("raw_name") == name)] + assert len(selected) == 1, (file, kind, name, selected) + return selected[0] + + +def index_for(root, sources, *, import_roots=None): + per_file, nodes, edges = corpus(root, sources) + return build_qml_index(nodes, edges, root=root, import_roots=import_roots), per_file, nodes, edges + + +def component_key(nodes, file="Main.qml", name=None): + return qml_metadata(named(nodes, file, "component", name)).get("component_key") + + +def module(uri, filename="Button.qml", *, extra=""): + return {f'{uri.replace(".", "/")}/qmldir': f"module {uri}\nButton 1.0 {filename}\n{extra}", + f'{uri.replace(".", "/")}/{filename}': "QtObject { property int value: 1 }"} diff --git a/tests/qml_test_helpers.py b/tests/qml_test_helpers.py new file mode 100644 index 0000000000..9e1d0e4a3e --- /dev/null +++ b/tests/qml_test_helpers.py @@ -0,0 +1,130 @@ +"""Public hand-checked QML inputs and structural assertions for production tests.""" +from __future__ import annotations + +import json +from pathlib import Path +import re +from typing import Any + + +# Independent declarations-only contract: JS bodies and expressions remain opaque +# in QML-01, while owner scopes and imported spellings remain inspectable. +DECLARATIONS = '''import QtQuick +import QtQuick.Controls 6.5 as Controls +import "widgets" as Local +import "helpers.js" as Helpers +Item { + id: root + property int count: 1 + required property string title + readonly property int doubled: count * 2 + default property list extras + property alias editorText: editor.text + signal activated(int value, string label) + function bump(step: int): int { + count += step; + activated(count, title); + return count; + } + Controls.TextField { + id: editor + property string caption: "kept" + text: root.title + } +} +''' + +DECLARATION_KINDS = {"file", "component", "inline_component", "object", "property_group", "property", "signal", "function", "import"} + + +def write_qml(root: Path, relative: str = "Main.qml", source: str = DECLARATIONS) -> Path: + """Write only synthetic input, retaining original UTF-8 and line endings.""" + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(source.encode("utf-8")) + return path + + +def qml(node: dict[str, Any]) -> dict[str, Any]: + return node["metadata"]["qml"] + + +def by_kind(result: dict[str, Any], kind: str) -> list[dict[str, Any]]: + return [node for node in result["nodes"] if qml(node)["kind"] == kind] + + +def one(result: dict[str, Any], kind: str, name: str | None = None) -> dict[str, Any]: + selected = by_kind(result, kind) + if name is not None: + selected = [node for node in selected if qml(node)["raw_name"] == name] + assert len(selected) == 1, (kind, name, len(selected)) + return selected[0] + + +def canonical(result: dict[str, Any]) -> str: + """Compare facts without assuming parallel/cache emission list ordering.""" + facts = {key: sorted(result.get(key, []), key=lambda item: json.dumps(item, sort_keys=True)) for key in ("nodes", "edges", "raw_calls", "diagnostics")} + return json.dumps(facts, ensure_ascii=False, sort_keys=True) + + +def assert_contains(result: dict[str, Any], parent: dict[str, Any], child: dict[str, Any]) -> None: + assert any(edge["source"] == parent["id"] and edge["target"] == child["id"] and edge["relation"] == "contains" for edge in result["edges"]) + + +def assert_spans(result: dict[str, Any], source: bytes) -> None: + """Independently recompute zero-based byte/point positions from original input.""" + for node in result["nodes"]: + span = qml(node)["span"] + assert 0 <= span["start_byte"] <= span["end_byte"] <= len(source) + for side in ("start", "end"): + offset = span[f"{side}_byte"] + prefix = source[:offset] + assert span[f"{side}_row"] == prefix.count(b"\n") + assert span[f"{side}_column"] == offset - prefix.rfind(b"\n") - 1 + assert node["source_location"].startswith(f'L{span["start_row"] + 1}') + + +def assert_no_machine_paths(value: Any, root: Path) -> None: + """Check nested values even when exception repr/JSON doubled Windows separators.""" + if isinstance(value, dict): + for child in value.values(): + assert_no_machine_paths(child, root) + elif isinstance(value, (list, tuple)): + for child in value: + assert_no_machine_paths(child, root) + elif isinstance(value, str): + assert root.as_posix() not in re.sub(r"[\\/]+", "/", value) + + +# Probe the built package in a fresh interpreter, never importing its source +# checkout. Core absence is forced before imports at the optional-library boundary. +WHEEL_PROBE = r''' +import importlib.abc +import json +from pathlib import Path +import sys + +target, source_root, mode = map(Path, sys.argv[1:]) +sys.path.insert(0, str(target)) + +class MissingOptionalGrammar(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "tree_sitter_language_pack" or fullname.startswith("tree_sitter_language_pack."): + raise ModuleNotFoundError("QML optional parser unavailable in core-only probe") + +if str(mode) == "core": + sys.meta_path.insert(0, MissingOptionalGrammar()) + for name in list(sys.modules): + if name.startswith("tree_sitter_language_pack"): + del sys.modules[name] + +import graphify +from graphify.extractors.qml import extract_qml +from graphify.extract import extract_python + +assert Path(graphify.__file__).resolve().is_relative_to(target.resolve()) +qml_result = extract_qml(source_root / "Main.qml", root=source_root) +python_result = extract_python(source_root / "safe.py", root=source_root) +assert any(node["label"] == "safe()" for node in python_result["nodes"]) +print(json.dumps({"qml": qml_result, "python_ok": True})) +''' diff --git a/tests/qt_adoption_fixture.py b/tests/qt_adoption_fixture.py new file mode 100644 index 0000000000..1c0dfb003f --- /dev/null +++ b/tests/qt_adoption_fixture.py @@ -0,0 +1,44 @@ +"""Public hand-checked declared API types; no analyzed project code executes.""" +from __future__ import annotations + +NATIVE = '''class Service : public QObject { Q_OBJECT + Q_PROPERTY(int count READ count) +public: int count() { return 1; } + Q_INVOKABLE void refresh() {} +signals: void ready(); +}; +class Backend : public QObject { Q_OBJECT + Q_PROPERTY(Service* service READ service) +public: Service *service() { throw 7; } +}; +class Factory { +public: Backend *makeBackend() { throw 7; } + Backend *backend; +}; +''' + +QML = '''import QtQml +QtObject { + property int count: backend.service.count + function handleReady() {} + function run() { + backend.service.refresh() + backend.service.ready.connect(handleReady) + } + property Connections subscription: Connections { + target: backend.service + function onReady() { handleReady() } + } +} +''' + + +def sources(root, expression="factory->makeBackend()", *, qml=QML, native=NATIVE): + """Literal loading establishes component scope; factory bodies are irrelevant.""" + return { + "native.h": native, + "access.cpp": '#include "native.h"\nvoid use(Factory *factory, Backend *backend) {' + ' QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("backend", ' + + expression + '); engine.load(QUrl("' + (root / "Main.qml").as_uri() + '")); }\n', + "Main.qml": qml, + } diff --git a/tests/qt_analysis_helpers.py b/tests/qt_analysis_helpers.py new file mode 100644 index 0000000000..5720f0d739 --- /dev/null +++ b/tests/qt_analysis_helpers.py @@ -0,0 +1,20 @@ +"""Accepted production extraction boundary for Qt source overlay regressions.""" +from graphify.extract import extract +from graphify.extractors.qt_cpp_facts import qt_metadata + + +def analysis(root, sources): + paths = [] + for name, source in sources.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8", newline="") + paths.append(path) + result = extract(paths, root=root, cache_root=root, parallel=False) + assert not result.get("qml_failures"), result.get("qml_failures") + return result + + +def sites(result, kind, operation=None): + return [node for node in result["nodes"] if qt_metadata(node).get("kind") == kind + and (operation is None or qt_metadata(node).get("operation") == operation)] diff --git a/tests/qt_combined_adoption_fixture.py b/tests/qt_combined_adoption_fixture.py new file mode 100644 index 0000000000..306e1e4a5c --- /dev/null +++ b/tests/qt_combined_adoption_fixture.py @@ -0,0 +1,40 @@ +"""Public Qt 6 adoption corpus: metadata, typed providers and inherited overload sites.""" +from __future__ import annotations + +from tests.qt_adoption_fixture import NATIVE, QML, sources + + +CMAKE = "qt_add_qml_module(app URI Public.Adoption VERSION 1.0 QML_FILES Main.qml SOURCES native.h access.cpp)\n" +QMAKE = """QT += qml quick +CONFIG += c++17 +QML_IMPORT_NAME = Public.Adoption +QML_IMPORT_VERSION = 1.0 +HEADERS += $$PWD/native.h +SOURCES += $$PWD/access.cpp +QML_FILES += $$PWD/Main.qml +QML_IMPORT_PATH += $$PWD/imports +QMLPATHS += $$PWD/imports +""" + + +def corpus(root, build_system): + """Bodies throw if executed; source analysis consumes declared API types only.""" + app = root / "app" + native = NATIVE.replace("class Backend : public QObject", "class\nBackend : public Middle") + native = "class Grand : public QObject { Q_OBJECT signals: void changed(); };\n" + \ + "class Middle : public Grand { Q_OBJECT };\n" + native + native = native.replace("public: Service *service()", "public: Backend(); Backend(int value); Service *service()") + data = sources(app, native=native) + data["access.cpp"] += "Backend::Backend() : Backend(0) { emit changed(); }\nBackend::Backend(int value) {}\n" + data["CMakeLists.txt" if build_system == "cmake" else "application.pro"] = CMAKE if build_system == "cmake" else QMAKE + # Explicit analysis configuration grants this module visibility. A qmake + # tooling/build hint alone cannot enlarge the accepted corpus or lookup roots. + data["imports/Public/Extra/qmldir"] = "module Public.Extra\nExtra 1.0 Extra.qml\n" + data["imports/Public/Extra/Extra.qml"] = "import QtQml\nQtObject { property int value: 3 }\n" + data["Consumer.qml"] = "import Public.Extra 1.0\nExtra {}\n" + for name, source in data.items(): + path = app / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(source.encode("utf-8")) + (root / "keep.py").write_text("def retained(): return 7\n", encoding="utf-8") + return app diff --git a/tests/qt_consumer_helpers.py b/tests/qt_consumer_helpers.py new file mode 100644 index 0000000000..c0e77b99c3 --- /dev/null +++ b/tests/qt_consumer_helpers.py @@ -0,0 +1,31 @@ +"""Saved production graph fixtures shared across CLI/HTML/MCP acceptance.""" +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qml_facts import qml_metadata +from tests.qt_analysis_helpers import analysis + + +def saved_graph(root): + result = analysis(root, {"Main.qml": '''import QtQml +QtObject { id: root; property int input: 1; property int output: input; + signal changed(); onChanged: { output = input } + function run() { changed() } +}''', "Other.qml": 'import QtQml\nQtObject { property int input: 2 }'}) + graph = build_from_json(result, root=root, directed=True) + output = root / "graph.json" + assert to_json(graph, {}, str(output), force=True) + component = next(node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "component") + prop = next(node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "property" and qml_metadata(node).get("raw_name") == "input") + read = next(node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "read" and qml_metadata(node).get("reference") == "input") + return output, result, component, prop, read + + +def cli(monkeypatch, capsys, graph, *arguments): + import graphify.__main__ as main + # Query stamps belong to the isolated persisted fixture, not the checkout. + monkeypatch.chdir(graph.parent) + monkeypatch.setattr(main, "_check_skill_version", lambda *_: None) + monkeypatch.setattr(main.sys, "argv", ["graphify", *arguments, "--graph", str(graph)]) + capsys.readouterr() + main.main() + return capsys.readouterr().out.strip() diff --git a/tests/qt_cpp_test_helpers.py b/tests/qt_cpp_test_helpers.py new file mode 100644 index 0000000000..09aa726290 --- /dev/null +++ b/tests/qt_cpp_test_helpers.py @@ -0,0 +1,54 @@ +"""Public synthetic Qt inputs using Graphify's actual canonical C++ extractor.""" +from __future__ import annotations + +import copy +from pathlib import Path + +from graphify.extract import extract +from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp +from graphify.extractors.qt_cpp_facts import qt_metadata + +HEADER = '''class Backend : public QObject { + Q_OBJECT + QML_NAMED_ELEMENT(Backend) + Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged) +public: + int value() const; + Q_INVOKABLE int next(int amount); + void ordinary(int value); +signals: + void valueChanged(int value); +public slots: + void setValue(int value); +private: + Q_INVOKABLE void hidden(); +}; +''' + + +def write(root, name, source): + path = Path(root) / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(source.encode()) + return path + + +def collect(root, files): + paths = [write(root, name, source) for name, source in files.items()] + accepted = extract(paths, root=Path(root), cache_root=Path(root) / ".probe-cache", parallel=False) + # A production integration may already have run overlays. This independent + # collector boundary borrows only existing generic declarations unchanged. + nodes = [node for node in accepted["nodes"] if not qt_metadata(node)] + ids = {node["id"] for node in nodes} + edges = [edge for edge in accepted["edges"] if edge["source"] in ids and edge["target"] in ids] + snapshot = copy.deepcopy((nodes, edges)) + results = [{"nodes": [], "edges": []} for _ in paths] + enrich_qt_cpp(paths, results, root=root, accepted_nodes=nodes, accepted_edges=edges) + assert (nodes, edges) == snapshot + return {"nodes": nodes + [node for result in results for node in result["nodes"]], + "edges": edges + [edge for result in results for edge in result["edges"]], "per_file": results} + + +def facts(result, kind, name=None): + return [node for node in result["nodes"] if (md := qt_metadata(node)).get("kind") == kind + and (name is None or md.get("raw_name") == name)] diff --git a/tests/shell_portability.py b/tests/shell_portability.py new file mode 100644 index 0000000000..cc4ecde59e --- /dev/null +++ b/tests/shell_portability.py @@ -0,0 +1,56 @@ +"""Admit explicit shell executables and preserve native/MSYS file identity. + +Shells may report a POSIX spelling for a file created by native Windows Python. +Convert only through the reporting shell's own path mapper; drive-prefix guesses +cannot handle MSYS mounts such as ``/tmp`` and can hide a wrong candidate. +""" +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess + + +def select_shell_executable(shell: str = "sh") -> str: + """Select Bash/sh only from configured PATH, then launch its absolute identity.""" + if shell not in {"bash", "sh"}: + raise AssertionError("only Bash/sh are admitted by shell fixtures") + configured = os.environ.get("PATH", "") + if not configured: + raise AssertionError(f"{shell} is unavailable in configured PATH") + # A qualified candidate avoids Windows which/CreateProcess adding CWD or + # system directories before PATH. Explicit empty/dot entries still admit CWD. + for directory in configured.split(os.pathsep): + candidate = Path(directory or os.curdir).absolute() / shell + executable = shutil.which(str(candidate)) + if executable is None: + continue + identity = Path(executable) + if not identity.is_absolute() or not identity.is_file(): + raise AssertionError(f"{shell} selection is not an absolute executable file") + return str(identity.resolve(strict=True)) + raise AssertionError(f"{shell} is unavailable in configured PATH") + + +def resolve_shell_path(value: str, *, shell: str = "sh") -> Path: + """Resolve one existing absolute path in the reporting shell's namespace.""" + if os.name == "nt" and value.startswith("/"): + executable = select_shell_executable(shell) + system = subprocess.check_output( + [executable, "-c", "uname -s"], text=True, + ).strip() + if not system.startswith(("MSYS_", "MINGW", "CYGWIN_")): + raise AssertionError(f"unsupported Windows shell path namespace: {system}") + # Use this shell installation's mapper, not another cygpath on PATH. + mapper = Path(executable).with_name("cygpath.exe") + mapped = subprocess.check_output( + [str(mapper), "-w", "--", value], text=True, + ).splitlines() + if len(mapped) != 1 or not mapped[0]: + raise AssertionError("shell path conversion did not return one path") + value = mapped[0] + path = Path(value) + if not path.is_absolute(): + raise AssertionError(f"shell reported a relative file identity: {value!r}") + return path.resolve(strict=True) diff --git a/tests/test_atomic_replace_retention.py b/tests/test_atomic_replace_retention.py new file mode 100644 index 0000000000..4271ea139f --- /dev/null +++ b/tests/test_atomic_replace_retention.py @@ -0,0 +1,220 @@ +"""REQ-QML-018-AC06: replacement failure never masquerades as committed data.""" +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import stat + +import pytest + +from graphify.paths import os_replace_with_fallback, write_json_atomic, write_text_atomic + + +def fallback(monkeypatch): + """Only the OS replace boundary is forced; real copy/rename/unlink still run.""" + def unavailable(*_): + raise PermissionError("replace temporarily unavailable") + monkeypatch.setattr(os, "replace", unavailable) + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute") +@pytest.mark.parametrize("writer", ["text", "json", "replace"]) +def test_req_qml018_ac06_readonly_rejects_without_displacing_destination(tmp_path, writer): + """The actual OS rejection keeps bytes/permissions; repair then succeeds.""" + target, source = tmp_path / "graph.json", tmp_path / "source.tmp" + target.write_bytes(b'{"original":true}') + original = target.read_bytes() + source.write_bytes(b'{"new":true}') + target.chmod(stat.S_IREAD) + try: + with pytest.raises(PermissionError): + if writer == "text": + write_text_atomic(target, '{"new":true}') + elif writer == "json": + write_json_atomic(target, {"new": True}) + else: + os_replace_with_fallback(source, target) + assert target.read_bytes() == original + assert not target.stat().st_mode & stat.S_IWRITE + assert source.read_bytes() == b'{"new":true}' + assert {p.name for p in tmp_path.iterdir()} == {"graph.json", "source.tmp"} + finally: + target.chmod(stat.S_IWRITE) + write_text_atomic(target, '{"new":true}') + accepted = target.read_bytes() + write_text_atomic(target, '{"new":true}') + assert target.read_bytes() == accepted + + +def test_req_qml018_ac06_directory_is_not_a_replace_fallback_target(tmp_path, monkeypatch): + """A destination directory must not be renamed away to land a plain file.""" + source, target = tmp_path / "source.tmp", tmp_path / "directory" + source.write_bytes(b"NEW") + target.mkdir() + (target / "keep").write_bytes(b"KEEP") + fallback(monkeypatch) + with pytest.raises(PermissionError): + os_replace_with_fallback(source, target) + assert (target / "keep").read_bytes() == b"KEEP" + assert source.read_bytes() == b"NEW" + assert {p.name for p in tmp_path.iterdir()} == {"source.tmp", "directory"} + + +@pytest.mark.parametrize("phase,existing", [ + ("copy", True), ("backup", True), ("land", True), + ("source_cleanup", True), ("backup_cleanup", True), + ("copy", False), ("land", False), ("source_cleanup", False), +]) +def test_req_qml018_ac06_fallback_failure_restores_prior_state(tmp_path, monkeypatch, phase, existing): + """Each actual fallback phase can fail without publishing a false success.""" + source, target = tmp_path / "source.tmp", tmp_path / "graph.json" + source.write_bytes(b"NEW") + if existing: + target.write_bytes(b"ORIGINAL") + fallback(monkeypatch) + real_copy, real_rename, real_unlink = shutil.copy2, os.rename, os.unlink + raised = False + + def reject(): + nonlocal raised + raised = True + raise OSError("injected " + phase) + + def copy(start, destination, *args, **kwargs): + if phase == "copy" and not raised: + reject() + return real_copy(start, destination, *args, **kwargs) + + def rename(start, destination, *args, **kwargs): + if not raised and ((phase == "backup" and Path(start) == target) + or (phase == "land" and Path(start).name.startswith(".gfy-replace-") + and Path(destination) == target)): + reject() + return real_rename(start, destination, *args, **kwargs) + + def unlink(path, *args, **kwargs): + candidate = Path(path) + if not raised and ((phase == "source_cleanup" and candidate == source) + or (phase == "backup_cleanup" and candidate.name.startswith(".gfy-replace-bak-") + and candidate.read_bytes() == b"ORIGINAL")): + reject() + return real_unlink(path, *args, **kwargs) + + monkeypatch.setattr(shutil, "copy2", copy) + monkeypatch.setattr(os, "rename", rename) + monkeypatch.setattr(os, "unlink", unlink) + with pytest.raises(OSError, match="injected " + phase): + os_replace_with_fallback(source, target) + assert raised and source.read_bytes() == b"NEW" + assert target.read_bytes() == b"ORIGINAL" if existing else not target.exists() + assert {p.name for p in tmp_path.iterdir()} == ({"source.tmp", "graph.json"} if existing else {"source.tmp"}) + os_replace_with_fallback(source, target) + assert target.read_bytes() == b"NEW" and not source.exists() + assert {p.name for p in tmp_path.iterdir()} == {"graph.json"} + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute") +def test_req_qml018_ac06_readonly_source_is_consumed_on_successful_fallback(tmp_path, monkeypatch): + """The move contract consumes an authorized source, including its read-only bit.""" + source, target = tmp_path / "source.tmp", tmp_path / "graph.json" + source.write_bytes(b"NEW") + target.write_bytes(b"ORIGINAL") + source.chmod(stat.S_IREAD) + fallback(monkeypatch) + try: + os_replace_with_fallback(source, target) + assert target.read_bytes() == b"NEW" and not source.exists() + assert not target.stat().st_mode & stat.S_IWRITE + assert {p.name for p in tmp_path.iterdir()} == {"graph.json"} + finally: + for path in (source, target): + if path.exists(): + path.chmod(stat.S_IWRITE) + + +def test_req_qml018_ac06_restore_failure_keeps_recovery_backup(tmp_path, monkeypatch): + """A second OS failure must expose integrity failure and retain original bytes.""" + source, target = tmp_path / "source.tmp", tmp_path / "graph.json" + source.write_bytes(b"NEW") + target.write_bytes(b"ORIGINAL") + fallback(monkeypatch) + real_rename = os.rename + + def fail_landing_and_restore(start, destination): + if Path(destination) == target: + raise OSError("landing/restore unavailable") + return real_rename(start, destination) + + monkeypatch.setattr(os, "rename", fail_landing_and_restore) + with pytest.raises(OSError, match="rollback failed"): + os_replace_with_fallback(source, target) + backups = list(tmp_path.glob(".gfy-replace-bak-*.tmp")) + assert len(backups) == 1 and backups[0].read_bytes() == b"ORIGINAL" + assert source.read_bytes() == b"NEW" and not target.exists() + assert list(tmp_path.glob(".gfy-replace-*.tmp")) == backups + monkeypatch.setattr(os, "rename", real_rename) + real_rename(backups[0], target) + os_replace_with_fallback(source, target) + assert target.read_bytes() == b"NEW" + assert {p.name for p in tmp_path.iterdir()} == {"graph.json"} + + +def test_req_qml018_ac06_absent_destination_restore_failure_has_no_fake_backup(tmp_path, monkeypatch): + """An unavoidable second OS failure reports recovery without inventing a backup.""" + source, target = tmp_path / "source.tmp", tmp_path / "graph.json" + source.write_bytes(b"NEW") + fallback(monkeypatch) + real_rename, real_unlink = os.rename, os.unlink + + def unlink(path, *args, **kwargs): + if Path(path) == source: + raise OSError("source cleanup unavailable") + return real_unlink(path, *args, **kwargs) + + def rename(start, destination): + if Path(start) == target: + raise OSError("candidate rollback unavailable") + return real_rename(start, destination) + + monkeypatch.setattr(os, "unlink", unlink) + monkeypatch.setattr(os, "rename", rename) + with pytest.raises(OSError, match="rollback failed; manual recovery required"): + os_replace_with_fallback(source, target) + assert source.read_bytes() == target.read_bytes() == b"NEW" + assert not list(tmp_path.glob(".gfy-*.tmp")) + monkeypatch.setattr(os, "unlink", real_unlink) + monkeypatch.setattr(os, "rename", real_rename) + target.unlink() + os_replace_with_fallback(source, target) + assert target.read_bytes() == b"NEW" and not source.exists() + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute") +def test_req_qml018_ac06_readonly_source_cleanup_rollback_retains_attributes(tmp_path, monkeypatch): + """A consumed source is reconstructed before restoring a failed cleanup transaction.""" + source, target = tmp_path / "source.tmp", tmp_path / "graph.json" + source.write_bytes(b"NEW") + source.chmod(stat.S_IREAD) + target.write_bytes(b"ORIGINAL") + fallback(monkeypatch) + real_unlink = os.unlink + rejected = False + + def unlink(path, *args, **kwargs): + nonlocal rejected + if not rejected and Path(path).name.startswith(".gfy-replace-bak-") and Path(path).read_bytes() == b"ORIGINAL": + rejected = True + raise OSError("backup cleanup unavailable") + return real_unlink(path, *args, **kwargs) + + monkeypatch.setattr(os, "unlink", unlink) + try: + with pytest.raises(OSError, match="backup cleanup unavailable"): + os_replace_with_fallback(source, target) + assert target.read_bytes() == b"ORIGINAL" and target.stat().st_mode & stat.S_IWRITE + assert source.read_bytes() == b"NEW" and not source.stat().st_mode & stat.S_IWRITE + assert {p.name for p in tmp_path.iterdir()} == {"source.tmp", "graph.json"} + finally: + if source.exists(): + source.chmod(stat.S_IWRITE) diff --git a/tests/test_ci_windows_tools.py b/tests/test_ci_windows_tools.py new file mode 100644 index 0000000000..4d90e8bba8 --- /dev/null +++ b/tests/test_ci_windows_tools.py @@ -0,0 +1,109 @@ +"""REQ-CORE-004: execute hosted Windows tool selection through real PowerShell.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import subprocess + +import pytest + + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="Native Windows PowerShell application discovery") +WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml" + + +def assignment(tool): + """The workflow owns the executable selector; tests execute its actual RHS.""" + matches = re.findall(rf"^\s*\$proof{tool.capitalize()}\s*=\s*(.+)$", + WORKFLOW.read_text(encoding="utf-8"), re.MULTILINE) + assert len(matches) == 1, f"Expected one workflow-owned {tool} selector" + return matches[0] + + +def node_version_guard(): + lines = [line.strip() for line in WORKFLOW.read_text(encoding="utf-8").splitlines() + if line.strip().startswith("if ((& $proofNode --version)")] + assert len(lines) == 1, "Expected one workflow-owned Node admission guard" + return lines[0] + + +@pytest.fixture +def powershell(): + """Use the real native shell even when validation excludes installers from PATH.""" + shell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe" + assert shell.is_file(), "Required native Windows PowerShell is unavailable" + return shell + + +def real_binary(tool): + """uv is an explicit proof dependency, retained off the corpus consumer PATH.""" + selected = os.environ.get("GRAPHIFY_TEST_UV") if tool == "uv" else None + selected = selected or shutil.which(tool) + if not selected: + pytest.skip(f"Real {tool} executable unavailable; hosted proof must admit its test dependency") + path = Path(selected).resolve() + assert path.is_file(), f"Selected {tool} executable is missing" + return path + + +def execute(shell, body, directories): + """Restrict only the child process PATH; no installer or machine setting changes.""" + environment = dict(os.environ) + system = Path(os.environ["SystemRoot"]) / "System32" + environment["PATH"] = os.pathsep.join(str(path) for path in (*directories, system)) + script = "$ErrorActionPreference = 'Stop'\n" + script += "[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)\n" + body + return subprocess.run([str(shell), "-NoProfile", "-NonInteractive", "-Command", script], + env=environment, capture_output=True, text=True, encoding="utf-8", timeout=30) + + +@pytest.mark.parametrize("tool", ["node", "uv"]) +def test_req_core004_ac02_workflow_executes_first_real_path_application( + tmp_path, powershell, tool): + """Two actual binaries reproduce hosted discovery; one scalar first path executes.""" + original = real_binary(tool) + preferred = tmp_path / "preferred application directory" / (tool + ".exe") + preferred.parent.mkdir() + shutil.copy2(original, preferred) + variable = "$proof" + tool.capitalize() + body = variable + " = " + assignment(tool) + "\n" + body += "$version = (& " + variable + " --version)\n" + body += "if ($LASTEXITCODE -ne 0) { throw 'Selected real application cannot execute' }\n" + body += "$candidates = @((Get-Command " + tool + " -CommandType Application).Source)\n" + body += "[ordered]@{selected=" + variable + ";version=$version;candidates=$candidates} | ConvertTo-Json -Compress\n" + result = execute(powershell, body, (preferred.parent, original.parent)) + assert result.returncode == 0, result.stderr + evidence = json.loads(result.stdout) + assert isinstance(evidence["selected"], str) and Path(evidence["selected"]) == preferred + assert [Path(path) for path in evidence["candidates"]] == [preferred, original] + assert evidence["version"], "Actual selected executable must complete its version command" + if tool == "node": + identity = execute(powershell, variable + " = " + assignment(tool) + + "\n& " + variable + " -p process.execPath", (preferred.parent, original.parent)) + assert identity.returncode == 0 and Path(identity.stdout.strip()) == preferred + + +@pytest.mark.parametrize("tool", ["node", "uv"]) +def test_req_core004_ac03_missing_workflow_application_has_no_fallback(powershell, tool): + """The actual selector rejects absent PATH authority before invoking any application.""" + result = execute(powershell, "$proof" + tool.capitalize() + " = " + assignment(tool), ()) + assert result.returncode != 0 + assert "CommandNotFoundException" in result.stderr and f"'{tool}'" in result.stderr + + +def test_req_core004_ac03_node_guard_rejects_real_incompatible_application( + tmp_path, powershell): + """A real uv binary named node cannot bypass the workflow's exact Node version gate.""" + original = real_binary("uv") + incompatible = tmp_path / "incompatible application directory" / "node.exe" + incompatible.parent.mkdir() + shutil.copy2(original, incompatible) + body = "$proofNode = " + assignment("node") + "\n" + body += "& $proofNode --version\n" + node_version_guard() + result = execute(powershell, body, (incompatible.parent,)) + assert result.returncode != 0 + assert result.stdout.startswith("uv "), "The real incompatible executable must run" + assert "The reviewed Node.js test interpreter was not selected" in result.stderr diff --git a/tests/test_cpp_constructor_ownership.py b/tests/test_cpp_constructor_ownership.py new file mode 100644 index 0000000000..3701625c2e --- /dev/null +++ b/tests/test_cpp_constructor_ownership.py @@ -0,0 +1,220 @@ +"""REQ-QML-008-AC02: generic constructor facts retain proven class ownership.""" +import copy +import base64 + +import pytest + +from graphify.extract import extract, extract_cpp +from graphify.extractors.base import _file_stem, _make_id +from graphify.extractors.cpp_constructors import bind_cpp_constructors, constructor_merge_allowed + + +def corpus(root, header, implementation): + """Use the real generic producer and aggregate with explicit corpus ownership.""" + paths = [root / "backend.hpp", root / "backend.cpp"] + for path, source in zip(paths, (header, '#include "backend.hpp"\n' + implementation)): + path.write_text(source, encoding="utf-8", newline="") + return paths + + +def owner_edges(result, constructor): + return [edge for edge in result["edges"] if edge["target"] == constructor["id"] + and edge["relation"] == "method" and edge["confidence"] == "EXTRACTED"] + + +@pytest.mark.parametrize("prototype,definition", [ + ("Backend();", "Backend::Backend() : value(0) {}"), + ("explicit Backend(int initial = 0);", "Backend::Backend(int initial) : value(initial) {}"), +]) +def test_req_qml008_ac02_constructor_prototypes_are_callable_methods(tmp_path, prototype, definition): + """Header declaration nodes are missing before the correction, unlike ordinary methods.""" + paths = corpus(tmp_path, f"class Backend {{public: {prototype} int value;}};", definition) + direct = extract_cpp(paths[0]) + constructors = [node for node in direct["nodes"] if node.get("label") == ".Backend()"] + assert len(constructors) == 1 and constructors[0].get("_callable") is True + owners = owner_edges(direct, constructors[0]) + assert len(owners) == 1 + assert next(node for node in direct["nodes"] if node["id"] == owners[0]["source"])["label"] == "Backend" + + +@pytest.mark.parametrize("namespace,qualification", [ + ("", "Backend::Backend"), + ("Shared", "Shared::Backend::Backend"), + ("Shared", "namespace Shared { Backend::Backend"), +]) +def test_req_qml008_ac02_constructor_definition_retains_id_and_exact_accepted_owner(tmp_path, namespace, qualification): + """Explicit scope and header declarations authorize ownership without rewriting a definition ID.""" + header = "class Backend {public: explicit Backend(int initial); int value;};" + if namespace: + header = f"namespace {namespace} {{ {header} }}" + implementation = qualification + "(int initial) : value(initial) {}" + if qualification.startswith("namespace"): + implementation += " }" + paths = corpus(tmp_path, header, implementation) + direct = extract_cpp(paths[1]) + definition = next(node for node in direct["nodes"] if node.get("_callable") and not node.get("_callable_class")) + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + constructors = [node for node in result["nodes"] if node.get("_callable") and "Backend" in node["label"] + and not node.get("_callable_class")] + candidate = next(node for node in constructors if node.get("definition_file") == "backend.cpp" + or node.get("source_file") == "backend.cpp") + assert candidate["id"] == "backend_" + definition["id"].removeprefix(_make_id(_file_stem(paths[1])) + "_") + owners = owner_edges(result, candidate) + assert len({edge["source"] for edge in owners}) == 1 + owner = next(node for node in result["nodes"] if node["id"] == owners[0]["source"]) + assert owner.get("_callable_class") is True and owner["source_file"] == "backend.hpp" + + +@pytest.mark.parametrize("header,implementation", [ + ("class Backend {public: Backend(double value);};", "Backend::Backend(int value) {}"), + ("class Backend;", "Backend::Backend(int value) {}"), + ("namespace Other {class Backend {public: Backend(int value);};}", "Foreign::Backend::Backend(int value) {}"), + ("class Backend {public: Backend(int value); Backend(const int other);};", "Backend::Backend(int value) {}"), + ("class Backend {public: Backend(...);};", "Backend::Backend() {}"), + ("class Backend {public: Backend(unsigned int value);};", "Backend::Backend(unsignedint value) {}"), +]) +def test_req_qml008_ac02_constructor_ownership_requires_complete_matching_unambiguous_proof(tmp_path, header, implementation): + """Wrong signatures, incomplete classes, foreign scopes and duplicate signatures cannot invent an owner.""" + paths = corpus(tmp_path, header, implementation) + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + definition = next(node for node in result["nodes"] if node.get("_callable") + and (node.get("source_file") == "backend.cpp" or node.get("definition_file") == "backend.cpp")) + assert not owner_edges(result, definition) + + +def test_req_qml008_ac02_constructor_binding_keeps_unrelated_generic_edges(tmp_path): + """An ownership correction must preserve the producer's existing ID and field references.""" + paths = corpus(tmp_path, "class Backend {public: Backend(int initial); int value;};", + "int helper(){return 1;} Backend::Backend(int initial) : value(initial) {value=helper();}") + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + functions = {node["label"]: node for node in result["nodes"] if node.get("_callable") and not node.get("_callable_class")} + constructor = next(node for node in functions.values() if "Backend" in node["label"]) + assert any(edge["source"] == constructor["id"] and edge["target"] == functions["helper()"]["id"] + and edge["relation"] == "calls" for edge in result["edges"]) + before = copy.deepcopy(result) + repeated = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert repeated == before + + +@pytest.mark.parametrize("corruption", ["wrong_line", "foreign_header", "inferred_parent", "conflicting_parent", "corrupt_transport", "foreign_edge", "wrong_edge_line", "corrupt_span", "not_callable", "reversed_columns", "conflicting_contains"]) +def test_req_qml008_ac02_constructor_join_rejects_foreign_or_corrupt_accepted_proof(tmp_path, corruption): + """Neither lexical names nor a collision replace exact source and extracted-parent authority.""" + paths = corpus(tmp_path, "class Backend {public: Backend(int initial);};", "Backend::Backend(int initial) {}") + header, implementation = (extract_cpp(path) for path in paths) + nodes = copy.deepcopy(header["nodes"] + implementation["nodes"]) + edges = copy.deepcopy(header["edges"] + implementation["edges"]) + definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition") + prototype = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "declaration") + if corruption == "wrong_line": + definition["source_location"] = "L99" + elif corruption == "foreign_header": + prototype["source_file"] = "foreign.hpp" + elif corruption == "inferred_parent": + for edge in edges: + if edge.get("relation") == "method": + edge["confidence"] = "INFERRED" + elif corruption == "conflicting_parent": + edges.append({"source": "foreign_class", "target": definition["id"], "relation": "method", "confidence": "EXTRACTED"}) + elif corruption in {"foreign_edge", "wrong_edge_line"}: + for edge in edges: + if edge.get("relation") == "method": + edge["source_file" if corruption == "foreign_edge" else "source_location"] = "foreign.hpp" if corruption == "foreign_edge" else "L99" + elif corruption == "corrupt_span": + definition["metadata"]["cpp_constructor"]["span"]["start_row"] = True + elif corruption == "not_callable": + definition["_callable"] = False + elif corruption == "reversed_columns": + definition["metadata"]["cpp_constructor"]["span"].update(start_column=5, end_column=0) + elif corruption == "conflicting_contains": + other_path = tmp_path / "other.hpp" + other_path.write_text("class Other {};", encoding="utf-8") + other = next(node for node in extract_cpp(other_path)["nodes"] if node.get("_callable_class")) + nodes.append(other) + edges.append({"source": other["id"], "target": definition["id"], "relation": "contains", + "confidence": "EXTRACTED", "source_file": definition["source_file"]}) + else: + definition["metadata"]["cpp_constructor"]["owner_b64"] = "invalid!" + bind_cpp_constructors(nodes, edges) + assert not definition["metadata"]["cpp_constructor"].get("owner_bound") + assert not constructor_merge_allowed([prototype, definition]) + + +def test_req_qml008_ac02_long_qualified_owner_stays_bounded_through_metadata_sanitation(tmp_path): + """Oversized identifiers cannot be silently truncated into another constructor owner.""" + namespace = "N" * 385 + paths = corpus(tmp_path, f"namespace {namespace} {{ class Backend {{public: Backend(int initial);}}; }}", + f"{namespace}::Backend::Backend(int initial) {{}}") + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + definition = next(node for node in result["nodes"] if node.get("_callable") and node["source_file"] == "backend.cpp") + assert not owner_edges(result, definition) + + +def test_req_qml008_ac02_corrupted_owner_cannot_relabel_another_actual_constructor(tmp_path): + """Changing only copied owner transport must never bind an Impostor body to Genuine.""" + paths = corpus(tmp_path, "class Genuine {public: Genuine(int value);};", "Impostor::Impostor(int value) {}") + results = [extract_cpp(path) for path in paths] + nodes = copy.deepcopy([node for result in results for node in result["nodes"]]) + edges = copy.deepcopy([edge for result in results for edge in result["edges"]]) + definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition") + definition["metadata"]["cpp_constructor"]["owner_b64"] = base64.b64encode(b"Genuine").decode() + bind_cpp_constructors(nodes, edges) + assert not definition["metadata"]["cpp_constructor"].get("owner_bound") + assert not owner_edges({"edges": edges}, definition) + + +def test_req_qml008_ac02_reference_parameter_names_do_not_change_constructor_type_proof(tmp_path): + """Different parameter names retain the same reference type without erasing word tokens.""" + paths = corpus(tmp_path, "class Backend {public: Backend(const int &left);};", "Backend::Backend(const int &right) {}") + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + definition = next(node for node in result["nodes"] if node.get("definition_file") == "backend.cpp") + assert owner_edges(result, definition) + + +def test_req_qml008_ac02_generic_constructor_facts_keep_original_bom_crlf_unicode_spans(tmp_path): + """Class, prototype and implementation metadata spans all address the original disk bytes.""" + header = "\ufeff// caf\u00e9 \u96ea\r\nclass Backend {\r\npublic:\r\n explicit Backend(const int &left);\r\n};\r\n" + implementation = "\ufeff// caf\u00e9 \u96ea\r\nBackend::Backend(const int &right) {}\r\n" + paths = [tmp_path / "backend.hpp", tmp_path / "backend.cpp"] + for path, source in zip(paths, (header, implementation)): + path.write_bytes(source.encode("utf-8")) + for path, expected in zip(paths, ((b"class Backend {\r\npublic:\r\n explicit Backend(const int &left);\r\n}", + b"explicit Backend(const int &left);"), (b"Backend::Backend(const int &right) {}",))): + result = extract_cpp(path) + slices = [] + for node in result["nodes"]: + for key in ("cpp_class", "cpp_constructor"): + fact = node.get("metadata", {}).get(key) + if fact: + span = fact["span"] + slices.append(path.read_bytes()[span["start_byte"]:span["end_byte"]]) + assert slices == list(expected) + + +def test_req_qml008_ac02_corrupted_namespace_owner_cannot_escape_original_lexical_scope(tmp_path): + """A copied owner field cannot redirect a Shared constructor into an accepted Other class.""" + paths = [tmp_path / "shared.hpp", tmp_path / "other.hpp", tmp_path / "backend.cpp"] + sources = ["namespace Shared {class Backend {public: Backend(int value);};}", + "namespace Other {class Backend {public: Backend(int value);};}", + "namespace Shared { Backend::Backend(int value) {} }"] + for path, source in zip(paths, sources): + path.write_text(source, encoding="utf-8") + results = [extract_cpp(path) for path in paths] + nodes = copy.deepcopy([node for result in results for node in result["nodes"]]) + edges = copy.deepcopy([edge for result in results for edge in result["edges"]]) + definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition") + definition["metadata"]["cpp_constructor"]["owner_b64"] = base64.b64encode(b"Other::Backend").decode() + bind_cpp_constructors(nodes, edges) + assert not definition["metadata"]["cpp_constructor"].get("owner_bound") + assert not owner_edges({"edges": edges}, definition) + + +@pytest.mark.parametrize("source", ["namespace {class Backend {public: Backend(int value);}; Backend::Backend(int value) {}}", + "struct {int value;} unnamed; int helper(){return 1;}"]) +def test_req_qml008_ac02_anonymous_cpp_scopes_do_not_break_generic_extraction(tmp_path, source): + """Unsupported anonymous constructor scopes keep ordinary C++ output without producer exceptions.""" + path = tmp_path / "anonymous.cpp" + path.write_text(source, encoding="utf-8") + result = extract_cpp(path) + assert not result.get("error") and not result.get("parse_errors") + assert result["nodes"] + assert not any(node.get("metadata", {}).get("cpp_constructor") for node in result["nodes"]) diff --git a/tests/test_cpp_overload_identity.py b/tests/test_cpp_overload_identity.py new file mode 100644 index 0000000000..7c07a14011 --- /dev/null +++ b/tests/test_cpp_overload_identity.py @@ -0,0 +1,203 @@ +"""INC-QML-15: accepted constructor signatures retain independent source owners.""" +from __future__ import annotations + +import copy +import base64 +import hashlib + +import pytest + +from graphify.extract import extract, extract_cpp +from graphify.extractors.cpp_constructors import bind_cpp_constructors + + +def constructors(result): + """Select producer contracts rather than labels shared by every overload.""" + return [node for node in result["nodes"] + if node.get("metadata", {}).get("cpp_constructor")] + + +def source_pair(root, declarations="Backend(); Backend(int value);", definitions=None, namespace=""): + """Sibling files establish exact class ownership without executing constructors.""" + header = f"class Backend {{public: {declarations}}};" + body = definitions or "Backend::Backend() {}\nBackend::Backend(int value) {}\n" + if namespace: + header = f"namespace {namespace} {{ {header} }}" + body = body.replace("Backend::Backend", f"{namespace}::Backend::Backend") + paths = [root / "backend.hpp", root / "backend.cpp"] + for path, source in zip(paths, (header, '#include "backend.hpp"\n' + body)): + path.write_bytes(source.encode()) + return paths + + +@pytest.mark.parametrize("namespace", ["", "Shared", "Shared::Inner"]) +def test_req_qml008_ac02_overload_producer_ids_spans_and_exact_declaration_merge(tmp_path, namespace): + """Each real definition and prototype owns its signature, line and canonical containment.""" + paths = source_pair(tmp_path, namespace=namespace) + direct = [extract_cpp(path) for path in paths] + assert [len(constructors(result)) for result in direct] == [2, 2] + for path, result in zip(paths, direct): + assert len({node["id"] for node in constructors(result)}) == 2 + for node in constructors(result): + fact = node["metadata"]["cpp_constructor"] + span = fact["span"] + original = path.read_bytes()[span["start_byte"]:span["end_byte"]] + assert b"Backend(" in original and node["source_location"] == f"L{span['start_row'] + 1}" + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + merged = constructors(result) + assert len(merged) == 2 + assert {node["definition_location"] for node in merged} == {"L2", "L3"} + owner = next(node for node in result["nodes"] if node.get("_callable_class")) + assert all(any(edge["source"] == owner["id"] and edge["target"] == node["id"] + and edge["relation"] == "method" for edge in result["edges"]) for node in merged) + + +def test_req_qml008_ac03_inline_delegating_overloads_have_independent_fact_locations(tmp_path): + """Delegation is a distinct constructor body; it does not select the delegated overload target.""" + path = tmp_path / "inline.cpp" + path.write_bytes(b"class Backend {public:\n Backend() : Backend(0) {}\n Backend(int value) {}\n};") + result = extract_cpp(path) + found = constructors(result) + assert len(found) == 2 and len({node["id"] for node in found}) == 2 + assert {node["source_location"] for node in found} == {"L2", "L3"} + assert all(node["metadata"]["cpp_constructor"]["role"] == "definition" for node in found) + + +def test_req_qml008_ac02_corrupt_signature_never_binds_another_overload(tmp_path): + """A forged signature copied from a sibling overload cannot acquire its source authority.""" + paths = source_pair(tmp_path) + direct = [extract_cpp(path) for path in paths] + nodes = copy.deepcopy([node for result in direct for node in result["nodes"]]) + edges = copy.deepcopy([edge for result in direct for edge in result["edges"]]) + definitions = [node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition"] + assert len(definitions) == 2 + definitions[0]["metadata"]["cpp_constructor"]["signature"] = definitions[1]["metadata"]["cpp_constructor"]["signature"] + bind_cpp_constructors(nodes, edges) + assert definitions[0]["metadata"]["cpp_constructor"].get("owner_bound") is not True + + +@pytest.mark.parametrize("declared,defined", [ + ("", "void"), ("int value = 0", "const int renamed"), ("unsigned", "unsigned int other"), + ("const int &left", "int const &right"), ("int * const pointer", "int *other"), + ("QObject *parent = nullptr", "QObject *renamed"), ("const Backend ©", "Backend const &other"), + ("int &&value", "int &&other"), ("unsigned long long", "long unsigned long number"), +]) +def test_req_qml008_ac02_accepted_parameter_equivalence_joins_exact_original_spans(tmp_path, declared, defined): + """Parameter names/defaults and value-only CV cannot split one real constructor signature.""" + paths = source_pair(tmp_path, f"Backend({declared});", f"Backend::Backend({defined}) {{}}") + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + found = constructors(result) + assert len(found) == 1 and found[0]["definition_file"] == "backend.cpp" + fact = found[0]["metadata"]["cpp_constructor"] + span = fact["definition_span"] + assert paths[1].read_bytes()[span["start_byte"]:span["end_byte"]] == f"Backend::Backend({defined}) {{}}".encode() + + +@pytest.mark.parametrize("parameters", [ + ("int value", "double value"), ("int *value", "const int *value"), + ("int &value", "const int &value"), ("int &value", "int &&value"), + ("int **value", "int *const *value"), ("signed char value", "unsigned char value"), +]) +def test_req_qml008_ac02_distinct_pointer_reference_and_builtin_types_never_collapse(tmp_path, parameters): + """Pointee CV, reference kind and builtin signedness are meaningful signature distinctions.""" + decl = " ".join(f"Backend({value});" for value in parameters) + body = "\n".join(f"Backend::Backend({value}) {{}}" for value in parameters) + result = extract(source_pair(tmp_path, decl, body), root=tmp_path, cache_root=tmp_path, parallel=False) + assert len(constructors(result)) == 2 and len({node["id"] for node in constructors(result)}) == 2 + assert all(node.get("definition_file") == "backend.cpp" for node in constructors(result)) + + +@pytest.mark.parametrize("parameter", ["Alias value", "std::vector value", "void (*callback)(int)", + "int array[3]", "...", "Unknown &value"]) +def test_req_qml008_ac03_unsupported_signatures_keep_visible_distinct_occurrences_without_owner(tmp_path, parameter): + """Insufficient type/alias authority cannot bind a convenient constructor prototype.""" + paths = source_pair(tmp_path, f"Backend({parameter});", f"Backend::Backend({parameter}) {{}}") + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + found = constructors(result) + assert len(found) == 2 + definition = next(node for node in found if node["source_file"] == "backend.cpp") + assert definition["metadata"]["cpp_constructor"]["ambiguous"] is True + assert not any(edge["target"] == definition["id"] and edge["relation"] == "method" for edge in result["edges"]) + + +@pytest.mark.parametrize("corrupt", ["signature_b64", "signature", "signature_and_transport", "scope", "id", "span", "inline_parent"]) +def test_req_qml008_ac03_direct_binding_rejects_corrupted_identity_transport(tmp_path, corrupt): + """Source contracts, salted identity and real class containment must agree at the direct join.""" + paths = source_pair(tmp_path) + direct = [extract_cpp(path) for path in paths] + nodes = copy.deepcopy([node for result in direct for node in result["nodes"]]) + edges = copy.deepcopy([edge for result in direct for edge in result["edges"]]) + definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition") + fact = definition["metadata"]["cpp_constructor"] + if corrupt == "signature_b64": + fact["signature_b64"] = "invalid!" + elif corrupt == "signature": + fact["signature"] = "0" * 64 + elif corrupt == "signature_and_transport": + fact["signature_b64"] = base64.b64encode(b"ctor2\0double").decode() + fact["signature"] = hashlib.sha256(b"ctor2\0double").hexdigest() + elif corrupt == "scope": + fact["scope_b64"] = base64.b64encode(b"Wrong").decode() + elif corrupt == "id": + definition["id"] = "impostor_cppctor_" + fact["signature"] + elif corrupt == "span": + fact["span"]["start_row"] += 1 + else: + fact["owner_bound"] = True + edges.append({"source": "foreign", "target": definition["id"], "relation": "method", + "confidence": "EXTRACTED", "source_file": definition["source_file"], + "source_location": definition["source_location"]}) + bind_cpp_constructors(nodes, edges) + assert fact.get("owner_bound") is not True + + +def test_req_qml008_ac02_constructor_ids_survive_overload_addition_removal_and_parameter_rename(tmp_path): + """Signature identity is independent of overload cardinality and neighboring source positions.""" + paths = source_pair(tmp_path, "Backend(int value);", "Backend::Backend(int value) {}") + first = next(node for node in constructors(extract_cpp(paths[1]))) + paths = source_pair(tmp_path, "Backend(); Backend(int other);", "Backend::Backend() {}\nBackend::Backend(int renamed) {}") + later = next(node for node in constructors(extract_cpp(paths[1])) + if node["metadata"]["cpp_constructor"]["signature"] == first["metadata"]["cpp_constructor"]["signature"]) + assert later["id"] == first["id"] + + +def test_req_qml008_ac02_constructor_and_unrelated_method_ids_keep_independent_generic_calls(tmp_path): + """Every body owns its helper call; non-constructor function/member IDs retain upstream identity.""" + paths = source_pair(tmp_path, "Backend(); Backend(int); int work();", + "int helper(){return 1;}\nBackend::Backend() {helper();}\nBackend::Backend(int value) {helper();}\nint Backend::work(){return helper();}") + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + helper = next(node for node in result["nodes"] if node["label"] == "helper()") + method = next(node for node in result["nodes"] if node["label"] == ".work()") + assert method["id"] == "backend_backend_work" + assert all(any(edge["source"] == node["id"] and edge["target"] == helper["id"] and edge["relation"] == "calls" + for edge in result["edges"]) for node in constructors(result) + [method]) + + +@pytest.mark.parametrize("count", [51, 64]) +def test_req_qml008_ac02_signature_authority_keeps_arguments_beyond_display_list_limit(tmp_path, count): + """A differing last parameter must survive the graph's fifty-item display bound.""" + shared = [f"int value{index}" for index in range(count - 1)] + parameters = [", ".join(shared + ["int final"]), ", ".join(shared + ["double final"])] + paths = source_pair(tmp_path, " ".join(f"Backend({value});" for value in parameters), + "\n".join(f"Backend::Backend({value}) {{}}" for value in parameters)) + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + found = constructors(result) + assert len(found) == 2 and len({node["id"] for node in found}) == 2 + assert all(node.get("definition_file") == "backend.cpp" for node in found) + + +def test_req_qml008_ac03_parameter_overflow_cannot_turn_prefix_into_accepted_signature(tmp_path): + """Sixty-five parameters retain source occurrences while refusing truncated type proof.""" + parameter = ", ".join(f"int value{index}" for index in range(65)) + result = extract(source_pair(tmp_path, f"Backend({parameter});", f"Backend::Backend({parameter}) {{}}"), + root=tmp_path, cache_root=tmp_path, parallel=False) + assert len(constructors(result)) == 2 + assert all(node["metadata"]["cpp_constructor"]["ambiguous"] for node in constructors(result)) + + +@pytest.mark.parametrize("qualifier", ["const", "volatile", "const volatile"]) +def test_req_qml008_ac03_qualified_void_cannot_authorize_zero_argument_constructor(tmp_path, qualifier): + """Only sole unnamed unqualified void represents an empty parameter list.""" + paths = source_pair(tmp_path, "Backend();", f"Backend::Backend({qualifier} void) {{}}") + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not any(node.get("definition_file") for node in constructors(result)) diff --git a/tests/test_cpp_overload_source_integrity.py b/tests/test_cpp_overload_source_integrity.py new file mode 100644 index 0000000000..07e13eb6c9 --- /dev/null +++ b/tests/test_cpp_overload_source_integrity.py @@ -0,0 +1,127 @@ +"""INC-QML-15: constructor joins consume accepted file and class source bounds.""" +from __future__ import annotations + +import copy + +import pytest + +from graphify.extract import extract_cpp +from graphify.extractors.cpp_constructors import bind_cpp_constructors, constructor_merge_allowed +from tests.test_cpp_overload_identity import constructors, source_pair + + +def direct_pair(root, *, padding=False): + """Retain real extracted containment while mutating only transported source proof.""" + paths = source_pair(root, "Backend(int);", "Backend::Backend(int) {}") + if padding: + paths[0].write_bytes(paths[0].read_bytes() + + b" void unrelated_function_with_enough_source_bytes_to_hold_fake_span() {}") + extracted = [extract_cpp(path) for path in paths] + nodes = copy.deepcopy([node for result in extracted for node in result["nodes"]]) + edges = copy.deepcopy([edge for result in extracted for edge in result["edges"]]) + found = constructors({"nodes": nodes}) + prototype = next(node for node in found if node["metadata"]["cpp_constructor"]["role"] == "declaration") + definition = next(node for node in found if node["metadata"]["cpp_constructor"]["role"] == "definition") + return paths, nodes, edges, prototype, definition + + +def assert_unbound(nodes, edges, definition): + """Reject canonical merge; the direct header's existing same-ID method edge remains a source fact.""" + bind_cpp_constructors(nodes, edges) + assert definition["metadata"]["cpp_constructor"].get("owner_bound") is not True + assert not constructor_merge_allowed(constructors({"nodes": nodes})) + + +@pytest.mark.parametrize("role", ["declaration", "definition"]) +def test_req_qml008_ac03_out_of_file_constructor_transport_cannot_bind(tmp_path, role): + """A plausible same-line signature moved beyond the accepted file size has no source authority.""" + _, nodes, edges, prototype, definition = direct_pair(tmp_path) + candidate = prototype if role == "declaration" else definition + span = candidate["metadata"]["cpp_constructor"]["span"] + span["start_byte"] += 1_000_000 + span["end_byte"] += 1_000_000 + assert_unbound(nodes, edges, definition) + + +def test_req_qml008_ac03_in_file_prototype_outside_class_cannot_bind(tmp_path): + """A forged prototype on an unrelated same-line function remains inside the file, outside its class.""" + paths, nodes, edges, prototype, definition = direct_pair(tmp_path, padding=True) + original_class_end = paths[0].read_bytes().index(b" void unrelated") + span = prototype["metadata"]["cpp_constructor"]["span"] + width = span["end_byte"] - span["start_byte"] + start = original_class_end + 6 + span.update(start_byte=start, end_byte=start + width, start_column=start, end_column=start + width) + assert span["end_byte"] <= len(paths[0].read_bytes()) + assert_unbound(nodes, edges, definition) + + +@pytest.mark.parametrize("corruption", ["missing", "duplicate", "size_bool", "size_negative", "version_bool", "foreign_label"]) +def test_req_qml008_ac03_file_bound_transport_cannot_authorize_constructor(tmp_path, corruption): + """Absent, conflicting or corrupt admitted file authority cannot validate an otherwise plausible body.""" + _, nodes, edges, _, definition = direct_pair(tmp_path) + file = next(node for node in nodes if node["label"] == "backend.cpp") + proof = file["metadata"]["cpp_constructor_types"] + if corruption == "missing": + file["metadata"].pop("cpp_constructor_types") + elif corruption == "duplicate": + nodes.append(copy.deepcopy(file)) + elif corruption == "size_bool": + proof["source_size"] = True + elif corruption == "size_negative": + proof["source_size"] = -1 + elif corruption == "version_bool": + proof["contract_version"] = True + else: + file["label"] = "foreign.cpp" + assert_unbound(nodes, edges, definition) + + +@pytest.mark.parametrize("inline", [False, True]) +def test_req_qml008_ac03_out_of_file_class_span_cannot_authorize_constructor(tmp_path, inline): + """A complete class moved beyond its admitted file cannot authorize inline or out-of-line bodies.""" + if inline: + path = tmp_path / "inline.cpp" + path.write_bytes(b"class Backend {public: Backend(int) {}};") + result = extract_cpp(path) + nodes, edges = copy.deepcopy(result["nodes"]), copy.deepcopy(result["edges"]) + definition = constructors({"nodes": nodes})[0] + else: + _, nodes, edges, _, definition = direct_pair(tmp_path) + owner = next(node for node in nodes if node.get("_callable_class")) + span = owner["metadata"]["cpp_class"]["span"] + span["start_byte"] += 1_000_000 + span["end_byte"] += 1_000_000 + assert_unbound(nodes, edges, definition) + + +def test_req_qml008_ac02_original_file_and_class_bounds_accept_without_source_rereads(tmp_path, monkeypatch): + """Accepted EOF body bounds bind from existing AST facts even when corpus reads are unavailable.""" + from pathlib import Path + + paths, nodes, edges, prototype, definition = direct_pair(tmp_path) + assert definition["metadata"]["cpp_constructor"]["span"]["end_byte"] == len(paths[1].read_bytes()) + + def unavailable_read(*args, **kwargs): + raise AssertionError("Constructor binding must consume existing admitted AST proof") + + monkeypatch.setattr(Path, "read_bytes", unavailable_read) + monkeypatch.setattr(Path, "read_text", unavailable_read) + bind_cpp_constructors(nodes, edges) + assert definition["metadata"]["cpp_constructor"]["owner_bound"] is True + assert constructor_merge_allowed([prototype, definition]) + + +@pytest.mark.parametrize("prefix", [b"#include HEADER_MACRO\n", b"#include \n" * 51]) +def test_req_qml008_ac02_builtin_signature_file_bounds_independent_of_include_completeness(tmp_path, prefix): + """Conservative SDK include exclusion cannot remove accepted builtin constructor equivalence.""" + paths = source_pair(tmp_path, "Backend(int);", "Backend::Backend(int) {}") + paths[1].write_bytes(prefix + paths[1].read_bytes()) + result = [extract_cpp(path) for path in paths] + nodes = [node for item in result for node in item["nodes"]] + edges = [edge for item in result for edge in item["edges"]] + prototype, definition = constructors({"nodes": nodes}) + file = next(node for node in nodes if node["label"] == "backend.cpp") + assert file["metadata"]["cpp_constructor_types"]["complete"] is False + bind_cpp_constructors(nodes, edges) + assert definition["metadata"]["cpp_constructor"]["owner_bound"] is True + assert constructor_merge_allowed([prototype, definition]) diff --git a/tests/test_cpp_overload_type_authority.py b/tests/test_cpp_overload_type_authority.py new file mode 100644 index 0000000000..c61d23f5ab --- /dev/null +++ b/tests/test_cpp_overload_type_authority.py @@ -0,0 +1,216 @@ +"""INC-QML-15: named SDK constructor signatures require admitted type authority.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import extract, extract_cpp +from graphify.extractors.cpp_constructors import bind_cpp_constructors +from graphify.extractors.cpp_constructor_type_authority import ConstructorTypeAuthority +from graphify.paths import load_node_link_graph +from tests.test_cpp_overload_identity import constructors, source_pair + + +def project(root, shadow="using QObject = int;", include=True, transitive=False): + """Only already admitted AST inputs participate; include text executes nothing.""" + paths = source_pair(root, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}") + alias = root / "alias.hpp" + alias.write_bytes(shadow.encode()) + paths.append(alias) + dependency = alias.name + if transitive: + bridge = root / "bridge.hpp" + bridge.write_bytes(b'#include "alias.hpp"\n') + paths.append(bridge) + dependency = bridge.name + if include: + paths[1].write_bytes(f'#include "{dependency}"\n'.encode() + paths[1].read_bytes()) + return paths + + +@pytest.mark.parametrize("shadow", ["using QObject = int;", "typedef int QObject;", "class QObject;", + "struct QObject {};", "#if FEATURE\nusing QObject = int;\n#endif\n"]) +@pytest.mark.parametrize("transitive", [False, True]) +def test_req_qml008_ac03_included_sdk_shadow_cannot_bind_same_spelled_constructor(tmp_path, shadow, transitive): + """Different source type identities must not merge merely because both parameters spell QObject.""" + result = extract(project(tmp_path, shadow, transitive=transitive), root=tmp_path, cache_root=tmp_path, parallel=False) + definition = next(node for node in constructors(result) if node["source_file"] == "backend.cpp") + assert definition["metadata"]["cpp_constructor"]["type_authorized"] is False + assert not any(edge["target"] == definition["id"] and edge["relation"] == "method" for edge in result["edges"]) + + +@pytest.mark.parametrize("include,shadow", [(False, "using QObject = int;"), + (True, "namespace Unrelated {using QObject = int;}"), + (True, "class Other {public: using QObject = int;};"), + (True, "void ordinary(){using QObject = int;}"), + (True, "using Other = int;")]) +def test_req_qml008_ac02_unincluded_or_unrelated_scope_cannot_shadow_sdk_constructor(tmp_path, include, shadow): + """Available but invisible aliases do not invalidate an otherwise exact constructor.""" + result = extract(project(tmp_path, shadow, include=include), root=tmp_path, cache_root=tmp_path, parallel=False) + found = constructors(result) + assert len(found) == 1 and found[0]["definition_file"] == "backend.cpp" + + +@pytest.mark.parametrize("corruption", ["missing", "complete", "include_encoding", "include_bound", "scope_encoding", "span", "version"]) +def test_req_qml008_ac03_direct_constructor_dependency_proof_rejects_corruption(tmp_path, corruption): + """Malformed admitted transport never turns a hidden source shadow into SDK authority.""" + paths = project(tmp_path) + direct = [extract_cpp(path) for path in paths] + nodes = copy.deepcopy([node for result in direct for node in result["nodes"]]) + edges = copy.deepcopy([edge for result in direct for edge in result["edges"]]) + file = next(node for node in nodes if node["label"] == "alias.hpp") + fact = file["metadata"]["cpp_constructor_types"] + if corruption == "missing": + del file["metadata"]["cpp_constructor_types"] + elif corruption == "complete": + fact["complete"] = False + elif corruption == "include_encoding": + cpp_file = next(node for node in nodes if node["label"] == "backend.cpp") + cpp_file["metadata"]["cpp_constructor_types"]["includes"][0]["literal_b64"] = "invalid!" + elif corruption == "include_bound": + fact["includes"] = [{"literal_b64": "YQ==", "end_byte": 0}] * 51 + elif corruption == "scope_encoding": + fact["shadows"][0]["scope_b64"] = "invalid!" + elif corruption == "span": + fact["shadows"][0]["start_byte"] = True + else: + fact["contract_version"] = True + bind_cpp_constructors(nodes, edges) + definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition") + assert definition["metadata"]["cpp_constructor"].get("owner_bound") is not True + + +def test_req_qml008_ac03_same_class_later_alias_shadows_constructor_parameter_clause(tmp_path): + """Whole-class lookup cannot borrow SDK identity from a later member alias.""" + paths = source_pair(tmp_path, "Backend(QObject *parent); using QObject = int;", "Backend::Backend(QObject *parent) {}") + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not any(node.get("definition_file") for node in constructors(result)) + + +def test_req_qml008_ac03_include_walk_overflow_cannot_hide_sdk_shadow(tmp_path): + """A long admitted chain rejects authority before bounded traversal can discard an alternative.""" + paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}") + paths[1].write_bytes(b'#include "dependency0.hpp"\n' + paths[1].read_bytes()) + for index in range(129): + dependency = tmp_path / f"dependency{index}.hpp" + dependency.write_bytes(f'#include "dependency{index + 1}.hpp"\n'.encode() if index < 128 else b"using QObject = int;") + paths.append(dependency) + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not any(node.get("definition_file") for node in constructors(result)) + + +def test_req_qml008_ac02_direct_type_proof_survives_sanitation_export_reload(tmp_path): + """Producer provenance remains authoritative after the real graph serialization boundary.""" + paths = project(tmp_path, "using Other = int;") + direct = extract_cpp(paths[2]) + assert next(node for node in direct["nodes"] if node["label"] == "alias.hpp")["metadata"]["cpp_constructor_types"]["complete"] + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + graph = build_from_json(result, root=tmp_path) + output = tmp_path / "graph.json" + assert to_json(graph, {}, str(output)) + reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + nodes = [{"id": identity, **data} for identity, data in reloaded.nodes(data=True)] + authority = ConstructorTypeAuthority(nodes) + candidate = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor")) + assert authority.authorized(candidate, candidate["metadata"]["cpp_constructor"]) + + +@pytest.mark.parametrize("include", ['"unavailable.hpp"', '"../../unadmitted.hpp"', '"C:/private.hpp"', 'HEADER_MACRO']) +def test_req_qml008_ac03_unknown_quoted_dependency_never_authorizes_sdk_signature(tmp_path, include): + """Missing, escaping, absolute and dynamically expanded headers grant no compiler type identity.""" + paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}") + paths[1].write_bytes(f"#include {include}\n".encode() + paths[1].read_bytes()) + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not any(node.get("definition_file") for node in constructors(result)) + + +def test_req_qml008_ac02_shadow_transport_spans_address_original_bom_crlf_unicode_bytes(tmp_path): + """Encoded dependency decisions retain spans against the bytes actually read from disk.""" + path = tmp_path / "alias.hpp" + path.write_bytes('\ufeff// caf\u00e9 \u96ea\r\n#include "bridge.hpp"\r\nnamespace Nested { using QObject = int; }\r\n'.encode()) + result = extract_cpp(path) + fact = next(node for node in result["nodes"] if node["label"] == "alias.hpp")["metadata"]["cpp_constructor_types"] + original = path.read_bytes() + assert fact["source_size"] == len(original) + assert len(fact["includes"]) == len(fact["shadows"]) == 1 + for items, expected in ((fact["includes"], b'#include "bridge.hpp"\r\n'), (fact["shadows"], b"using QObject = int;")): + span = items[0]["span"] + assert original[span["start_byte"]:span["end_byte"]] == expected + + +@pytest.mark.parametrize("dependency", ["alias.hpp", "nested/alias.hpp"]) +def test_req_qml008_ac03_angle_local_shadow_cannot_grant_sdk_constructor_identity(tmp_path, dependency): + """An angle include may name an admitted source alias; compiler search order is unavailable.""" + paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}") + header = tmp_path / dependency + header.parent.mkdir(parents=True, exist_ok=True) + header.write_bytes(b"using QObject = int;") + paths.append(header) + # Even an admitted nested basename is a potential include-directory target. + literal = header.name + paths[1].write_bytes(f"#include <{literal}>\n".encode() + paths[1].read_bytes()) + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not any(node.get("definition_file") for node in constructors(result)) + definition = next(node for node in constructors(result) if node["source_file"] == "backend.cpp") + assert definition["metadata"]["cpp_constructor"]["type_authorized"] is False + + +def test_req_qml008_ac02_external_sdk_angle_keeps_unincluded_alias_independent(tmp_path): + """An unavailable Qt SDK header does not import an unrelated admitted alias file.""" + paths = project(tmp_path, include=False) + paths[1].write_bytes(b"#include \n#include \n" + paths[1].read_bytes()) + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + found = constructors(result) + assert len(found) == 1 and found[0]["definition_file"] == "backend.cpp" + + +@pytest.mark.parametrize("corruption", ["missing", "corrupt", "duplicate"]) +def test_req_qml008_ac03_angle_candidate_rejects_missing_corrupt_or_duplicate_authority(tmp_path, corruption): + """An admitted angle candidate cannot vanish from the shadow check through damaged metadata.""" + paths = project(tmp_path, include=False) + paths[1].write_bytes(b"#include \n" + paths[1].read_bytes()) + results = [extract_cpp(path) for path in paths] + nodes = copy.deepcopy([node for result in results for node in result["nodes"]]) + edges = copy.deepcopy([edge for result in results for edge in result["edges"]]) + candidate = next(node for node in nodes if node["label"] == "alias.hpp") + if corruption == "missing": + candidate["metadata"].pop("cpp_constructor_types") + elif corruption == "corrupt": + candidate["metadata"]["cpp_constructor_types"]["complete"] = False + else: + nodes.append(copy.deepcopy(candidate)) + bind_cpp_constructors(nodes, edges) + definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition") + assert definition["metadata"]["cpp_constructor"].get("owner_bound") is not True + + +@pytest.mark.parametrize("kind", ["missing", "invalid", "overflow"]) +def test_req_qml008_ac03_angle_include_transport_limits_reject_sdk_identity(tmp_path, kind): + """Missing delimiter authority or truncated include decisions grant no SDK signature proof.""" + paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}") + prefix = b"#include \n" * (51 if kind == "overflow" else 1) + paths[1].write_bytes(prefix + paths[1].read_bytes()) + results = [extract_cpp(path) for path in paths] + nodes = copy.deepcopy([node for result in results for node in result["nodes"]]) + edges = copy.deepcopy([edge for result in results for edge in result["edges"]]) + cpp = next(node for node in nodes if node["label"] == "backend.cpp") + include = cpp["metadata"]["cpp_constructor_types"]["includes"][0] + if kind == "missing": + include.pop("kind") + elif kind == "invalid": + include["kind"] = "expanded" + bind_cpp_constructors(nodes, edges) + definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition") + assert definition["metadata"]["cpp_constructor"].get("owner_bound") is not True + + +def test_req_qml008_ac03_escaping_angle_literal_cannot_establish_compiler_include_search(tmp_path): + """A parent traversal has no accepted compiler include root at this analysis boundary.""" + paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}") + paths[1].write_bytes(b"#include <../alias.hpp>\n" + paths[1].read_bytes()) + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not any(node.get("definition_file") for node in constructors(result)) diff --git a/tests/test_cpp_overload_updates.py b/tests/test_cpp_overload_updates.py new file mode 100644 index 0000000000..854cc5363f --- /dev/null +++ b/tests/test_cpp_overload_updates.py @@ -0,0 +1,222 @@ +"""INC-QML-15: exact overload identity across durable cold/manual/watch updates.""" +from __future__ import annotations + +import os +import stat + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.watch import _rebuild_code +import graphify.watch as watch +from graphify.cache import cache_dir +import graphify.cache as cache +import graphify.qt_incremental as qt_incremental +import graphify.extractors.cpp_constructor_signature as constructor_signatures +from graphify.extractors.base import _make_id +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated +from tests.test_qt_overload_consumers import fixture + + +def project(root): + """Real Qt constructor bodies and unrelated Python exercise the shared production pipeline.""" + fixture(root) + (root / "keep.py").write_bytes(b"def helper(): return 7\n\ndef retained(): return helper()\n") + return [root / "backend.hpp", root / "backend.cpp"] + + +def identities(graph): + return {data["metadata"]["cpp_constructor"]["signature"]: identity + for identity, data in graph.nodes(data=True) if data.get("metadata", {}).get("cpp_constructor")} + + +def assert_owners(graph): + """Each constructor body owns four native mechanisms with its distinct canonical ID.""" + accepted = set(identities(graph).values()) + owned = [qt_metadata(data) for _, data in graph.nodes(data=True) + if qt_metadata(data).get("kind") in {"emission", "qml_access", "qml_load", "qml_root"}] + assert len(accepted) == 2 and len(owned) == 8 + assert all(md["status"] == "resolved" and md["owner_id"] in accepted for md in owned) + assert {md["owner_id"] for md in owned} == accepted + + +def replace_types(paths, old, new): + for path in paths: + path.write_bytes(path.read_bytes().replace(old, new)) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac01_ac04_overload_signature_edit_removal_and_repeat_parity(tmp_path, monkeypatch, operation): + """Cold/warm/manual/watch retain exact IDs and erase retired signatures and locations.""" + paths = project(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + cold = clean(tmp_path, tmp_path / ".cold-cache") + assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold) + initial = run(tmp_path, monkeypatch, operation) + assert normalized(initial) == normalized(cold) + assert_owners(initial) + accepted, kept = identities(initial), unrelated(initial) + replace_types(paths, b"int ", b"double ") + changed = run(tmp_path, monkeypatch, operation, paths) + assert_owners(changed) + assert len(set(identities(changed).values()).intersection(accepted.values())) == 1 + assert not set(accepted.values()).difference(identities(changed).values()).intersection(changed) + assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".changed-cache")) + assert unrelated(changed) == kept + + # Parameter renaming changes occurrence bytes/spans, never the accepted + # signature ID; cached and rebuilt facts must carry the new original ranges. + before_rename = identities(changed) + replace_types(paths, b"value", b"renamedValue") + renamed = run(tmp_path, monkeypatch, operation, paths) + assert identities(renamed) == before_rename + assert normalized(renamed) == normalized(clean(tmp_path, tmp_path / ".rename-cache")) + + # Remove one declaration and definition rather than hiding it by a filter. + header = paths[0].read_bytes().replace(b" Backend(double renamedValue);", b"") + paths[0].write_bytes(header) + lines = paths[1].read_bytes().splitlines(keepends=True) + paths[1].write_bytes(b"".join(line for line in lines if b"Backend::Backend(double" not in line)) + removed = run(tmp_path, monkeypatch, operation, paths) + assert len(identities(removed)) == 1 + assert len([1 for _, data in removed.nodes(data=True) if qt_metadata(data).get("kind") == "emission"]) == 1 + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache")) + assert unrelated(removed) == kept + before = (tmp_path / "graphify-out/graph.json").read_bytes() + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed) + assert (tmp_path / "graphify-out/graph.json").read_bytes() == before + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("failure_kind", ["readonly", "late_os_error"]) +def test_req_qml011_ac04_overload_publication_failure_retains_cohort_and_retries(tmp_path, monkeypatch, operation, failure_kind): + """A real readonly manifest must retain prior graph/identity/checkpoint bytes before recovery.""" + paths = project(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + assert_owners(initial) + output = tmp_path / "graphify-out" + products = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root") + retained = {name: (output / name).read_bytes() for name in products} + ast_cache = cache_dir(tmp_path) + old_cache = {path: path.read_bytes() for path in ast_cache.rglob("*.json")} + assert old_cache # The actual versioned AST cache, not an empty historical filename pattern. + replace_types(paths, b"int ", b"double ") + manifest = output / "manifest.json" + original_mode = manifest.stat().st_mode + if failure_kind == "readonly" and os.name != "nt": + pytest.skip("Windows readonly destination contract; POSIX late real replacement failure runs separately") + actual_replace = os.replace + product_replace = watch.os_replace_with_fallback + blocker = tmp_path / ".replace-blocker" + blocker.mkdir() + + def reject_manifest(source, destination): + if destination.name == "manifest.json": + return actual_replace(source, blocker) # Actual OS rejection of file-to-directory replacement. + return product_replace(source, destination) + + if failure_kind == "readonly": + os.chmod(manifest, stat.S_IREAD) + else: + monkeypatch.setattr(watch, "os_replace_with_fallback", reject_manifest) + try: + if operation == "manual": + with pytest.raises(SystemExit) as failure: + run(tmp_path, monkeypatch, operation, paths) + assert failure.value.code == 1 + else: + assert not _rebuild_code(tmp_path, changed_paths=paths, no_cluster=True) + assert retained == {name: (output / name).read_bytes() for name in products} + assert all(path.read_bytes() == content for path, content in old_cache.items()) + finally: + os.chmod(manifest, original_mode | stat.S_IWRITE) + monkeypatch.setattr(watch, "os_replace_with_fallback", product_replace) + repaired = run(tmp_path, monkeypatch, operation, paths) + assert_owners(repaired) + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired-cache")) + assert normalized(repaired) != normalized(initial) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(repaired) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac04_cpp_only_overload_mismatch_rejects_native_owner_then_header_repair(tmp_path, monkeypatch, operation): + """Unchanged header prototypes cannot become owners of a newly incompatible constructor body.""" + paths = project(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + assert_owners(initial) + paths[1].write_bytes(paths[1].read_bytes().replace(b"int renamed", b"double renamed")) + mismatch = run(tmp_path, monkeypatch, operation, [paths[1]]) + emissions = [qt_metadata(data) for _, data in mismatch.nodes(data=True) if qt_metadata(data).get("kind") == "emission"] + assert len(emissions) == 2 and sum(md["status"] == "resolved" for md in emissions) == 1 + assert normalized(mismatch) == normalized(clean(tmp_path, tmp_path / ".mismatch-cache")) + paths[0].write_bytes(paths[0].read_bytes().replace(b"int value", b"double value")) + repaired = run(tmp_path, monkeypatch, operation, [paths[0]]) + assert_owners(repaired) + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".header-repair-cache")) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac04_partial_constructor_parse_retains_accepted_identity_and_recovery(tmp_path, monkeypatch, operation): + """An actual malformed overloaded C++ file cannot publish a partial new constructor set.""" + paths = project(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out" + retained = {name: (output / name).read_bytes() for name in ("graph.json", "manifest.json", ".qt_analysis.json")} + original = paths[1].read_bytes() + paths[1].write_bytes(original + b"\nBackend::Backend(double invalid {\n") + if operation == "manual": + with pytest.raises(SystemExit) as failure: + run(tmp_path, monkeypatch, operation, paths) + assert failure.value.code == 1 + else: + assert not _rebuild_code(tmp_path, changed_paths=paths, no_cluster=True) + assert retained == {name: (output / name).read_bytes() for name in retained} + paths[1].write_bytes(original) + restored = run(tmp_path, monkeypatch, operation, paths) + assert normalized(restored) == normalized(initial) + assert_owners(restored) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac04_schema9_name_id_fixture_migrates_unchanged_sources_to_exact_signatures(tmp_path, monkeypatch, operation): + """A same-package prior-ID producer seam supplies historical identity, not fabricated current graph data.""" + paths = project(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + current_schema, current_policy = cache._AST_CACHE_SCHEMA, qt_incremental.QT_POLICY_VERSION + assert current_schema >= 10 and current_policy >= 14 + original = {path: path.read_bytes() for path in paths} + + def prior_name_id(class_id, name, *_args): + return _make_id(class_id, name) + + # Only historical producer ID and epoch seams differ. Current declaration + # authority guards intentionally refuse these old identity contracts; this + # proves migration/retirement, not a replay of prior release semantics. + with monkeypatch.context() as legacy: + legacy.setattr(cache, "_AST_CACHE_SCHEMA", 9) + legacy.setattr(qt_incremental, "QT_POLICY_VERSION", 13) + legacy.setattr(constructor_signatures, "constructor_id", prior_name_id) + legacy.setattr("graphify.extractors.cpp_constructors.constructor_id", prior_name_id) + prior = run(tmp_path, monkeypatch, operation) + prior_ids = set(identities(prior).values()) + assert prior_ids and all("_cppctor_" not in identity for identity in prior_ids) + prior_cache = cache_dir(tmp_path) + assert "-s9" in prior_cache.name and list(prior_cache.rglob("*.json")) + migrated = run(tmp_path, monkeypatch, operation, []) + assert not prior_ids.intersection(migrated) + assert_owners(migrated) + assert all("_cppctor_" in identity for identity in identities(migrated).values()) + assert original == {path: path.read_bytes() for path in paths} + assert cache_dir(tmp_path) != prior_cache + assert normalized(migrated) == normalized(clean(tmp_path, tmp_path / ".migration-cache")) + before = (tmp_path / "graphify-out/graph.json").read_bytes() + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(migrated) + assert before == (tmp_path / "graphify-out/graph.json").read_bytes() diff --git a/tests/test_cpp_qualified_identity.py b/tests/test_cpp_qualified_identity.py new file mode 100644 index 0000000000..66ad307c3c --- /dev/null +++ b/tests/test_cpp_qualified_identity.py @@ -0,0 +1,160 @@ +"""REQ-QML-008/016/017: qualified native identities survive real publication.""" +from __future__ import annotations + +import base64 +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import extract_cpp +from graphify.extractors.base import _make_id, _file_stem +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_construction_authority import native + + +def classes(result): + return {base64.b64decode(node["metadata"]["cpp_class"]["qualified_name_b64"]).decode(): node + for node in result["nodes"] if node.get("metadata", {}).get("cpp_class")} + + +@pytest.mark.parametrize("source, names", [ + ("class Base {}; namespace Public { class Base {}; }", {"Base", "Public::Base"}), + ("namespace A_B {class Base {};} namespace A::B {class Base {};}", {"A_B::Base", "A::B::Base"}), + ("namespace Public {class Base {};} namespace PUBLIC {class Base {};}", {"Public::Base", "PUBLIC::Base"}), + ("class One {public: class Base {};}; class Two {public: class Base {};};", + {"One", "One::Base", "Two", "Two::Base"}), +]) +def test_req_qml008_ac02_qualified_class_bodies_keep_distinct_portable_identities(tmp_path, source, names): + path = tmp_path / "backend.h" + path.write_text(source, encoding="utf-8") + direct = extract_cpp(path) + accepted = classes(direct) + assert set(accepted) == names + assert len({node["id"] for node in accepted.values()}) == len(names) + assert all(not node["metadata"]["cpp_class"]["ambiguous"] for node in accepted.values()) + result = analysis(tmp_path, {"backend.h": source}) + assert set(classes(result)) == names + assert all(str(tmp_path).lower().replace("\\", "_") not in node["id"] for node in classes(result).values()) + original_ids = {name: node["id"] for name, node in classes(result).items()} + shifted = analysis(tmp_path, {"backend.h": "// source-only shift\n" + source}) + assert {name: node["id"] for name, node in classes(shifted).items()} == original_ids + + +def test_req_qml008_ac02_forward_facts_do_not_replace_unique_complete_body(tmp_path): + source = "class Base; class Base; class Base { Q_OBJECT }; namespace Public { class Base; class Base { Q_OBJECT }; }" + result = analysis(tmp_path, {"backend.h": source}) + accepted = classes(result) + assert set(accepted) == {"Base", "Public::Base"} + assert all(node["metadata"]["cpp_class"]["is_definition"] is True + and node["metadata"]["cpp_class"]["ambiguous"] is False for node in accepted.values()) + qt_classes = sites(result, "class") + assert sum(qt_metadata(node)["is_definition"] is False for node in qt_classes) == 3 + + +def test_req_qml017_ac04_same_file_native_ancestry_uses_exact_class_not_basename(tmp_path): + declarations = ('class Base : public QWidget { Q_OBJECT }; ' + 'namespace Public { class Base : public QObject { Q_OBJECT }; ' + 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }') + result, site, links = native(tmp_path, declarations) + assert qt_metadata(site)["status"] == "resolved" and len(links) == 1 + accepted = classes(result) + assert accepted["Base"]["id"] != accepted["Public::Base"]["id"] + qt_classes = {qt_metadata(node)["class_name"]: qt_metadata(node) for node in sites(result, "class")} + assert qt_classes["Public::Backend"]["canonical_base_names"] == ["Public::Base"] + assert qt_classes["Public::Base"]["class_id"] == accepted["Public::Base"]["id"] + + +def test_req_qml016_ac04_header_definition_events_keep_qualified_canonical_owners(tmp_path): + header = '''class Base : public QObject { Q_OBJECT +public: void run(); signals: void changed(); }; +namespace Public { class Base : public QObject { Q_OBJECT +public: void run(); signals: void changed(); }; } +''' + implementation = '#include "backend.h"\nvoid Base::run() { emit changed(); }\nvoid Public::Base::run() { emit changed(); }\n' + result = analysis(tmp_path, {"backend.h": header, "backend.cpp": implementation}) + accepted = classes(result) + assert set(accepted) == {"Base", "Public::Base"} + run = [node for node in result["nodes"] if node.get("definition_file") == "backend.cpp"] + assert len(run) == 2 and len({node["id"] for node in run}) == 2 + owner_links = [edge for edge in result["edges"] if edge.get("relation") == "method" + and edge["target"] in {node["id"] for node in run}] + assert {edge["source"] for edge in owner_links} == {node["id"] for node in accepted.values()} + emissions = sites(result, "emission") + assert len(emissions) == 2 and all(qt_metadata(node)["status"] == "resolved" for node in emissions) + graph = build_from_json(result, root=tmp_path) + output = tmp_path / "proof.json" + assert to_json(graph, {}, str(output), force=True) + payload = json.loads(output.read_text(encoding="utf-8")) + restored = load_node_link_graph(payload) + for site in emissions: + md = qt_metadata(site) + assert (tmp_path / "backend.cpp").read_bytes()[md["span"]["start_byte"]:md["span"]["end_byte"]] == b"changed()" + assert md["explicit_emit"] is True + links = [edge for edge in payload["links"] if edge["source"] == site["id"] and edge.get("context") == "qt_signal_emit"] + assert len(links) == 1 + edge = restored.edges[links[0]["source"], links[0]["target"]] + assert (edge["_src"], edge["_tgt"]) == (links[0]["source"], links[0]["target"]) + + +def test_req_qml008_ac02_conflicting_complete_bodies_remain_ambiguous(tmp_path): + result = analysis(tmp_path, {"backend.h": 'namespace Public { class Base {}; class Base {}; }'}) + accepted = classes(result) + assert accepted["Public::Base"]["metadata"]["cpp_class"]["ambiguous"] is True + + +@pytest.mark.parametrize("filename", ["backend.h", "b" * 205 + ".h"]) +def test_req_qml008_ac02_long_qualified_discriminator_survives_real_graph_transport(tmp_path, filename): + prefix = "N" * 330 + source = f"namespace {prefix} {{class Base {{public: void run() {{}};}};}} namespace {prefix.lower()} {{class Base {{public: void run() {{}};}};}}" + result = analysis(tmp_path, {filename: source}) + names = {prefix + "::Base", prefix.lower() + "::Base"} + accepted = classes(result) + assert set(accepted) == names and len({node["id"] for node in accepted.values()}) == 2 + graph = build_from_json(result, root=tmp_path) + output = tmp_path / "proof.json" + assert to_json(graph, {}, str(output), force=True) + restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + published = {base64.b64decode(data["metadata"]["cpp_class"]["qualified_name_b64"]).decode(): identity + for identity, data in restored.nodes(data=True) if data.get("metadata", {}).get("cpp_class")} + assert set(published) == names and len(set(published.values())) == 2 + for name in names: + assert published[name] == accepted[name]["id"] + assert any(source == published[name] and data.get("relation") == "method" + for source, _, data in restored.edges(published[name], data=True)) + + +def test_req_qml008_ac02_global_class_and_member_ids_retain_existing_contract(tmp_path): + path = tmp_path / "backend.h" + path.write_text("class Base {public: void run() {}};", encoding="utf-8") + result = extract_cpp(path) + owner = classes(result)["Base"] + assert owner["id"] == _make_id(_file_stem(path), "Base") + method = next(node for node in result["nodes"] if node.get("label") == ".run()") + assert method["id"] == _make_id(owner["id"], "run") + + +def test_req_qml008_ac02_inheritance_cannot_borrow_later_namespace_class_body(tmp_path): + result = analysis(tmp_path, {"backend.h": "class Base {}; namespace Public { class Derived : public Base {}; class Base {}; }"}) + accepted = classes(result) + edge = next(edge for edge in result["edges"] if edge["source"] == accepted["Public::Derived"]["id"] + and edge["relation"] == "inherits") + assert edge["target"] == accepted["Base"]["id"] != accepted["Public::Base"]["id"] + + +def test_req_qml008_ac02_original_bom_crlf_unicode_spans_survive_qualified_member_join(tmp_path): + header = '\ufeff// π原\r\nnamespace Public {\r\nclass Base : public QObject { Q_OBJECT\r\npublic: void run(); signals: void changed(); }; }\r\n' + implementation = '\ufeff// π原\r\n#include "backend.h"\r\nvoid Public::Base::run() { emit changed(); }\r\n' + result = analysis(tmp_path, {"backend.h": header, "backend.cpp": implementation}) + owner = classes(result)["Public::Base"] + span = owner["metadata"]["cpp_class"]["span"] + assert header.encode()[span["start_byte"]:span["end_byte"]].startswith(b"class Base") + method = next(node for node in result["nodes"] if node.get("definition_file") == "backend.cpp") + assert method["definition_location"] == "L3" + emission = sites(result, "emission")[0] + md = qt_metadata(emission) + assert md["owner_id"] == method["id"] and md["status"] == "resolved" + assert implementation.encode()[md["span"]["start_byte"]:md["span"]["end_byte"]] == b"changed()" diff --git a/tests/test_cpp_qualified_proof.py b/tests/test_cpp_qualified_proof.py new file mode 100644 index 0000000000..b6e32c5ff1 --- /dev/null +++ b/tests/test_cpp_qualified_proof.py @@ -0,0 +1,144 @@ +"""REQ-QML-008: qualified joins reject corrupt or competing source authority.""" +import copy + +import pytest + +from graphify.extract import extract_cpp +from graphify.extractors.cpp_class_proof import class_merge_allowed +from graphify.extractors.cpp_member_identity import canonicalize_cpp_members, member_merge_allowed + + +def accepted(root): + header = root / "backend.h" + implementation = root / "backend.cpp" + header.write_text("namespace Public { class Base { public: void run(int value); }; }", encoding="utf-8") + implementation.write_text('#include "backend.h"\nusing namespace Public;\nvoid Base::run(int value) {}', encoding="utf-8") + results = [extract_cpp(path) for path in (header, implementation)] + nodes = copy.deepcopy([node for result in results for node in result["nodes"]]) + edges = copy.deepcopy([edge for result in results for edge in result["edges"]]) + declaration = next(node for node in nodes if node.get("metadata", {}).get("cpp_member", {}).get("role") == "declaration") + definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_member", {}).get("role") == "definition") + owner = next(node for node in nodes if node.get("metadata", {}).get("cpp_class")) + return nodes, edges, declaration, definition, owner + + +def test_req_qml008_ac02_literal_using_namespace_binds_exact_prototype_and_raw_call_owner(tmp_path): + nodes, edges, declaration, definition, _ = accepted(tmp_path) + original = definition["id"] + raw_calls = [{"source": original, "target": "observed", "line": 3}] + canonicalize_cpp_members(nodes, edges, raw_calls) + assert definition["id"] == declaration["id"] != original + assert raw_calls == [{"source": declaration["id"], "target": "observed", "line": 3}] + assert member_merge_allowed([declaration, definition]) + assert not any(edge.get("target") == original for edge in edges) + + +@pytest.mark.parametrize("corruption", [ + "missing_definition_parent", "foreign_definition_parent", "inferred_definition_parent", + "wrong_definition_line", "reversed_columns", "noncallable", "corrupt_transport", + "missing_class", "incomplete_class", "ambiguous_class", "corrupt_class", + "missing_prototype_parent", "foreign_prototype_parent", "inferred_prototype_parent", + "wrong_prototype_line", "ambiguous_prototype", "wrong_signature", "wrong_using", +]) +def test_req_qml008_ac02_member_join_rejects_missing_corrupt_and_ambiguous_authority(tmp_path, corruption): + nodes, edges, declaration, definition, owner = accepted(tmp_path) + original = definition["id"] + member = definition["metadata"]["cpp_member"] + if corruption.endswith("definition_parent"): + links = [edge for edge in edges if edge["target"] == original and edge["relation"] == "contains"] + assert links + if corruption.startswith("missing"): + edges[:] = [edge for edge in edges if edge not in links] + else: + for edge in links: + edge["source_file" if corruption.startswith("foreign") else "confidence"] = ( + "foreign.cpp" if corruption.startswith("foreign") else "INFERRED") + elif corruption.endswith("prototype_parent"): + links = [edge for edge in edges if edge["target"] == declaration["id"] and edge["relation"] == "method"] + assert links + if corruption.startswith("missing"): + edges[:] = [edge for edge in edges if edge not in links] + else: + for edge in links: + edge["source_file" if corruption.startswith("foreign") else "confidence"] = ( + "foreign.h" if corruption.startswith("foreign") else "INFERRED") + elif corruption in {"wrong_definition_line", "wrong_prototype_line"}: + (definition if corruption == "wrong_definition_line" else declaration)["source_location"] = "L99" + elif corruption == "reversed_columns": + member["span"].update(start_column=100, end_column=0) + elif corruption == "noncallable": + definition["_callable"] = False + elif corruption == "corrupt_transport": + member["owner_b64"] = "invalid!" + elif corruption == "wrong_signature": + member["signature"] = "0" * 64 + elif corruption == "wrong_using": + member["using_b64"] = [] + elif corruption == "ambiguous_prototype": + declaration["metadata"]["cpp_member"]["ambiguous"] = True + elif corruption == "missing_class": + owner["metadata"].pop("cpp_class") + elif corruption == "corrupt_class": + owner["metadata"]["cpp_class"]["qualified_name_b64"] = "invalid!" + else: + owner["metadata"]["cpp_class"]["is_definition" if corruption == "incomplete_class" else "ambiguous"] = ( + False if corruption == "incomplete_class" else True) + before_calls = [{"source": original}] + canonicalize_cpp_members(nodes, edges, before_calls) + assert definition["id"] == original + assert before_calls == [{"source": original}] + assert not member.get("bound_owner_b64") + + +@pytest.mark.parametrize("corruption", ["qualified", "scope", "span", "missing", "ambiguous"]) +def test_req_qml008_ac02_class_merge_never_repairs_invalid_qualified_proof_by_label(tmp_path, corruption): + _, _, _, _, owner = accepted(tmp_path) + forward = copy.deepcopy(owner) + forward["source_file"] = "backend.cpp" + forward["metadata"]["cpp_class"]["is_definition"] = False + fact = forward["metadata"]["cpp_class"] + if corruption == "qualified": + fact["qualified_name_b64"] = "QmFzZQ==" # Base, not Public::Base. + elif corruption == "scope": + fact["scope_b64"] = "" + elif corruption == "span": + fact["span"]["start_row"] = True + elif corruption == "missing": + forward["metadata"].pop("cpp_class") + else: + fact["ambiguous"] = True + assert not class_merge_allowed([owner, forward]) + + +@pytest.mark.parametrize("declaration,definition,joins", [ + ("const &", "const &", True), ("const", "", False), + ("volatile", "const", False), ("&", "&&", False), +]) +def test_req_qml008_ac02_qualified_member_signature_preserves_cv_and_reference_overloads(tmp_path, declaration, definition, joins): + header = tmp_path / "backend.h" + implementation = tmp_path / "backend.cpp" + header.write_text(f"namespace Public {{class Base {{public: void run() {declaration};}};}}", encoding="utf-8") + implementation.write_text(f"void Public::Base::run() {definition} {{}}", encoding="utf-8") + results = [extract_cpp(path) for path in (header, implementation)] + nodes = [node for result in results for node in result["nodes"]] + edges = [edge for result in results for edge in result["edges"]] + prototype = next(node for node in nodes if node.get("metadata", {}).get("cpp_member", {}).get("role") == "declaration") + method = next(node for node in nodes if node.get("metadata", {}).get("cpp_member", {}).get("role") == "definition") + canonicalize_cpp_members(nodes, edges) + assert bool(method["metadata"]["cpp_member"].get("bound_owner_b64")) is joins + assert member_merge_allowed([prototype, method]) is joins + + +@pytest.mark.parametrize("extra", [0, 49]) +def test_req_qml008_ac02_using_namespace_bound_cannot_discard_competing_owner(tmp_path, extra): + from tests.qt_analysis_helpers import analysis + + header = "namespace N00 {class Base {public: void run();};} namespace ZZ {class Base {public: void run();};}" + filler = "".join(f"namespace N{index:02} {{}}" for index in range(1, extra + 1)) + usings = "\n".join(f"using namespace N{index:02};" for index in range(extra + 1)) + implementation = '#include "backend.h"\n' + usings + "\nusing namespace ZZ;\nvoid Base::run() {}" + result = analysis(tmp_path, {"backend.h": header + filler, "backend.cpp": implementation}) + definitions = [node for node in result["nodes"] if node.get("metadata", {}).get("cpp_member", {}).get("role") == "definition"] + assert len(definitions) == 1 and definitions[0]["source_file"] == "backend.cpp" + assert not definitions[0]["metadata"]["cpp_member"].get("bound_owner_b64") + assert not any(edge["target"] == definitions[0]["id"] and edge["relation"] == "method" for edge in result["edges"]) diff --git a/tests/test_cpp_qualified_updates.py b/tests/test_cpp_qualified_updates.py new file mode 100644 index 0000000000..d345553738 --- /dev/null +++ b/tests/test_cpp_qualified_updates.py @@ -0,0 +1,74 @@ +"""REQ-QML-008/016/017: changed qualified ownership survives real updates.""" +import base64 + +import pytest + +from graphify.affected import affected_nodes, load_graph +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated +from tests.test_qt_semantic_correction_updates import products + + +HEADER = '''class Base : public QWidget { Q_OBJECT }; +namespace Public { class Base : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service) +public: Q_INVOKABLE int fetch(); void run(); signals: void changed(); }; } +''' +IMPLEMENTATION = '#include "backend.h"\nint Public::Base::fetch() { return 2; }\nvoid Public::Base::run() { emit changed(); }\n' + + +def fixture(root): + sources = { + "backend.h": HEADER, "backend.cpp": IMPLEMENTATION, + "Main.qml": 'import Public.Tools 1.0\nService { function refresh() { fetch() } }', + "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h backend.cpp)', + "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n', + } + for name, source in sources.items(): + (root / name).write_text(source, encoding="utf-8", newline="") + + +def owners(graph): + return {base64.b64decode(data["metadata"]["cpp_class"]["qualified_name_b64"]).decode(): identity + for identity, data in graph.nodes(data=True) if data.get("metadata", {}).get("cpp_class")} + + +def evidence(graph): + emissions = [(identity, qt_metadata(data)) for identity, data in graph.nodes(data=True) + if qt_metadata(data).get("kind") == "emission"] + assert len(emissions) == 1 and emissions[0][1]["status"] == "resolved" + identity, metadata = emissions[0] + assert metadata["owner_id"] in graph + assert any(data.get("_src", source) == identity and data.get("context") == "qt_signal_emit" + for source, _, data in graph.edges(data=True)) + calls = [(source, target, data) for source, target, data in graph.edges(data=True) + if data.get("relation") == "calls" and qt_metadata(data).get("native_endpoint")] + assert calls + return identity, metadata + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml017_ac04_qualified_owner_edits_match_cold_warm_updates_and_consumers(tmp_path, monkeypatch, operation): + fixture(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + before = owners(initial) + assert set(before) == {"Base", "Public::Base"} + evidence(initial) + assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold")) + (tmp_path / "backend.h").write_text(HEADER.replace("namespace Public", "namespace PUBLIC"), encoding="utf-8") + (tmp_path / "backend.cpp").write_text(IMPLEMENTATION.replace("Public::", "PUBLIC::"), encoding="utf-8") + updated = run(tmp_path, monkeypatch, operation, [tmp_path / "backend.h", tmp_path / "backend.cpp"]) + after = owners(updated) + assert set(after) == {"Base", "PUBLIC::Base"} + assert after["Base"] == before["Base"] and after["PUBLIC::Base"] != before["Public::Base"] + assert before["Public::Base"] not in updated + identity, metadata = evidence(updated) + assert normalized(updated) == normalized(clean(tmp_path, tmp_path / ".changed")) + assert normalized(updated) == normalized(clean(tmp_path, tmp_path / ".changed")) + assert unrelated(initial) == unrelated(updated) + consumer = load_graph(tmp_path / "graphify-out/graph.json") + assert identity in {hit.node_id for hit in affected_nodes(consumer, metadata["target_id"], relations=["uses"], depth=1)} + unchanged = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(updated) + assert products(tmp_path) == unchanged diff --git a/tests/test_cpp_reference_return_updates.py b/tests/test_cpp_reference_return_updates.py new file mode 100644 index 0000000000..bd86db27c6 --- /dev/null +++ b/tests/test_cpp_reference_return_updates.py @@ -0,0 +1,140 @@ +"""REQ-QML-018-AC02/AC03 reference factories retain production refresh and recovery.""" +from __future__ import annotations + +import os +import stat + +import pytest + +import graphify.watch as watch +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_adoption_fixture import NATIVE, sources +from tests.test_qt_event_incremental_consumers import run +from tests.test_qt_final_incremental_parity import clean, normalized, unrelated +from tests.test_qt_product_publication import reject, snapshot + + +REFERENCE = NATIVE.replace("Backend *makeBackend()", "Backend &makeBackend()") + + +def fixture(root): + """Only native signature source changes; engine scope and QML clients stay fixed.""" + inputs = sources(root, native=REFERENCE) + inputs["keep.py"] = "def helper(): return 7\n\ndef retained(): return helper()\n" + "\n".join( + f"def keep_{index}(): return {index}" for index in range(30)) + for name, content in inputs.items(): + (root / name).write_bytes(content.encode()) + return root / "native.h" + + +def callable_and_provider(graph, *, accepted=True): + """Canonical factory remains a source callable independently of supported provider shape.""" + members = [(identity, qt_metadata(data)) for identity, data in graph.nodes(data=True) + if qt_metadata(data).get("kind") == "member" and qt_metadata(data).get("raw_name") == "makeBackend"] + assert len(members) == 1 and members[0][1]["generic_target_id"] in graph + target = members[0][1]["generic_target_id"] + assert graph.nodes[target]["_callable"] and graph.nodes[target]["label"] == ".makeBackend()" + bindings = {identity for identity, data in graph.nodes(data=True) if qt_metadata(data).get("kind") == "context_binding"} + accesses = {identity for identity, data in graph.nodes(data=True) if qt_metadata(data).get("kind") == "context_access"} + subscriptions = {identity for identity, data in graph.nodes(data=True) if qt_metadata(data).get("kind") == "context_subscription"} + if accepted: + assert len(bindings) == 1 and accesses and len(subscriptions) == 2 + assert all(qt_metadata(graph.nodes[identity])["status"] == "resolved" for identity in accesses | subscriptions) + assert all(qt_metadata(graph.nodes[identity])["provider_class_name"] == "Backend" for identity in bindings) + else: + assert not bindings and not accesses and not subscriptions + return target, bindings | accesses | subscriptions + + +def parity(graph, root, name): + """Entire durable source facts and typed edge metadata match cold and reused caches.""" + cache = root / (".clean-" + name) + assert normalized(graph) == normalized(clean(root, cache)) == normalized(clean(root, cache)) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac02_ac03_reference_factory_edits_keep_identity_and_drop_stale_provider_edges(tmp_path, monkeypatch, operation): + """C++-only pointer/ref edits retain generic identity; unavailable return removes exposure.""" + header = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + target, original_sites = callable_and_provider(initial) + parity(initial, tmp_path, "initial") + untouched = unrelated(initial) + header.write_bytes(NATIVE.encode()) + pointer = run(tmp_path, monkeypatch, operation, [header]) + assert callable_and_provider(pointer)[0] == target + parity(pointer, tmp_path, "pointer") + header.write_bytes(REFERENCE.replace("Backend &makeBackend()", "Unknown &makeBackend()").encode()) + removed = run(tmp_path, monkeypatch, operation, [header]) + assert callable_and_provider(removed, accepted=False)[0] == target + assert all(identity not in removed for identity in original_sites) + assert not any(data.get("context") in {"qt_context_member", "qt_context_subscription"} + for _, _, data in removed.edges(data=True)) + parity(removed, tmp_path, "unavailable") + header.write_bytes(REFERENCE.encode()) + restored = run(tmp_path, monkeypatch, operation, [header]) + assert normalized(restored) == normalized(initial) + assert callable_and_provider(restored)[0] == target + assert unrelated(restored) == untouched + current = snapshot(tmp_path / "graphify-out") + assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(restored) + assert snapshot(tmp_path / "graphify-out") == current + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("failure", ["syntax", "publication"]) +def test_req_qml018_ac02_ac03_reference_admission_failure_preserves_and_repairs(tmp_path, monkeypatch, operation, failure): + """Malformed reference source or real late publication fault cannot replace prior acceptance.""" + header = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + callable_and_provider(initial) + output = tmp_path / "graphify-out" + before = snapshot(output) + if failure == "syntax": + header.write_bytes(REFERENCE.replace("Backend &makeBackend()", "Backend &makeBackend( {").encode()) + reject(tmp_path, monkeypatch, operation, header) + else: + header.write_bytes(NATIVE.encode()) + replace = watch.os_replace_with_fallback + + def fail_late(source, destination): + if destination.name == "manifest.json": + raise OSError("public fixture reference publication failure") + return replace(source, destination) + + with monkeypatch.context() as fault: + fault.setattr(watch, "os_replace_with_fallback", fail_late) + reject(tmp_path, fault, operation, header) + assert snapshot(output) == before + header.write_bytes(REFERENCE.encode()) + repaired = run(tmp_path, monkeypatch, operation, [header]) + assert normalized(repaired) == normalized(initial) + parity(repaired, tmp_path, "repaired") + accepted = snapshot(output) + assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(repaired) + assert snapshot(output) == accepted + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary") +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac02_ac03_readonly_reference_edit_retains_products_and_retries(tmp_path, monkeypatch, operation): + """A genuine OS refusal retains prior graph and sidecars until repaired publication.""" + header = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + target, _ = callable_and_provider(initial) + output = tmp_path / "graphify-out" + before = snapshot(output) + header.write_bytes(NATIVE.encode()) + destination = output / "manifest.json" + destination.chmod(stat.S_IREAD) + try: + reject(tmp_path, monkeypatch, operation, header) + assert snapshot(output) == before + finally: + destination.chmod(stat.S_IWRITE) + repaired = run(tmp_path, monkeypatch, operation, [header]) + assert callable_and_provider(repaired)[0] == target + parity(repaired, tmp_path, "readonly-retry") diff --git a/tests/test_cpp_reference_returns.py b/tests/test_cpp_reference_returns.py new file mode 100644 index 0000000000..6c2ba9e087 --- /dev/null +++ b/tests/test_cpp_reference_returns.py @@ -0,0 +1,128 @@ +"""REQ-QML-018-AC02/AC03 canonical generic reference-return declarations.""" +import json + +import pytest + +from graphify.affected import affected_nodes +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import extract_cpp +from graphify.extractors.engine import _get_cpp_func_name +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_syntax import read_cpp, walk +from graphify.paths import load_node_link_graph +from graphify.serve import _query_graph_text +from tests.qt_adoption_fixture import NATIVE, sources +from tests.qt_analysis_helpers import analysis, sites + + +@pytest.mark.parametrize("result_type", ["Backend &", "Backend &&", "Backend", "Backend *"]) +def test_req_qml018_ac02_ac03_reference_return_has_canonical_callable(tmp_path, result_type): + """Generic producer owns the declaration; the provider lends its declared API.""" + native = NATIVE.replace("Backend *makeBackend()", result_type + " makeBackend()") + result = analysis(tmp_path, sources(tmp_path, native=native)) + member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend") + target = qt_metadata(member)["generic_target_id"] + assert target + direct = extract_cpp(tmp_path / "native.h") + direct_members = [node for node in direct["nodes"] if node.get("_callable") and node["label"] == ".makeBackend()"] + assert len(direct_members) == 1 + by_id = {node["id"]: node for node in result["nodes"]} + assert by_id[target]["label"] == ".makeBackend()" and by_id[target]["_callable"] + if result_type in {"Backend &", "Backend *"}: + assert len(sites(result, "context_binding")) == 1 + assert sites(result, "context_access") and len(sites(result, "context_subscription")) == 2 + else: + # Generic callable admission does not widen the one-object provider + # profile to value temporaries or unsupported rvalue references. + assert not sites(result, "context_binding") + + +@pytest.mark.parametrize("result_type", ["Backend &", "Backend &&", "const Backend &", "Backend *", "Backend"]) +def test_req_qml018_ac02_qualified_reference_declaration_and_definition_keep_one_callable(tmp_path, result_type): + """Wrapping the actual function declarator preserves full names and canonical joins.""" + header = "namespace Public { class Backend {}; class Factory { public: " + result_type + " create(); }; }\n" + body = '#include "native.h"\n' + result_type.replace("Backend", "Public::Backend") + " Public::Factory::create() { throw 7; }\n" + result = analysis(tmp_path, {"native.h": header, "native.cpp": body}) + candidates = [node for node in result["nodes"] if node.get("_callable") and node.get("label") == ".create()"] + assert len(candidates) == 1 + candidate = candidates[0] + assert candidate["source_file"] == "native.h" and candidate["definition_file"] == "native.cpp" + for name in ["native.h", "native.cpp"]: + unit = read_cpp(tmp_path / name, tmp_path) + declarators = [node for node in walk(unit.root) if node.type == "function_declarator"] + assert len(declarators) == 1 + outer = declarators[0].parent + value = _get_cpp_func_name(outer, unit.source) + assert value == ("create" if name.endswith(".h") else "Public::Factory::create") + + +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml018_ac02_ac03_reference_provider_spans_and_consumers_survive_reload(tmp_path, directed): + """Original BOM/CRLF/Unicode bytes and typed-provider evidence reach durable consumers.""" + native = ("\ufeff// café 雪\n" + NATIVE.replace("Backend *makeBackend()", "Backend &makeBackend()")).replace("\n", "\r\n") + result = analysis(tmp_path, sources(tmp_path, native=native)) + member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend") + metadata = qt_metadata(member) + span = metadata["span"] + assert (tmp_path / "native.h").read_bytes()[span["start_byte"]:span["end_byte"]] == b"Backend &makeBackend() { throw 7; }" + graph = build_from_json(result, root=tmp_path, directed=directed) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path)) + reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + assert metadata["generic_target_id"] in reloaded + assert qt_metadata(reloaded.nodes[member["id"]]) == metadata + accesses = sites(result, "context_access") + assert accesses and len(sites(result, "context_subscription")) == 2 + for access in accesses: + md = qt_metadata(reloaded.nodes[access["id"]]) + assert md["status"] == "resolved" and md["endpoint_proof"]["canonical_target_id"] in reloaded + assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(access["id"], data=True) + if data.get("context") == "qt_context_subscription") + assert "Main.qml" in _query_graph_text(reloaded, "backend service refresh", token_budget=4000) + access = next(node for node in accesses if qt_metadata(node)["endpoint_proof"]["kind"] == "function") + terminal = qt_metadata(access)["endpoint_proof"]["canonical_target_id"] + if directed: + assert reloaded.has_edge(access["id"], terminal) + assert any(hit.node_id == access["id"] for hit in affected_nodes(reloaded, terminal, depth=3)) + else: + edge = reloaded.edges[access["id"], terminal] + assert (edge["_src"], edge["_tgt"]) == (access["id"], terminal) + # INC-QML-30 separately owns affected's undirected logical direction; + # the persisted producer/consumer edge here retains its exact endpoints. + + +def test_req_qml018_ac02_reference_fallback_requires_one_function_child(): + """Malformed or competing grammar children cannot supply an arbitrary callable name.""" + class Syntax: + def __init__(self, kind, children=(), name=None): + self.type, self.children, self.name = kind, children, name + + def child_by_field_name(self, field): + return self.name if field == "declarator" else None + + # The real grammar defect is covered above. This isolated malformed-node + # guard checks fallback shape without inventing extraction behavior in a fake. + children = [Syntax("function_declarator"), Syntax("function_declarator")] + assert _get_cpp_func_name(Syntax("reference_declarator", children), b"") is None + assert _get_cpp_func_name(Syntax("reference_declarator", [Syntax("type_identifier")]), b"") is None + + +def test_req_qml018_ac03_unproven_factory_overloads_stay_unavailable(tmp_path): + """Reference admission does not authorize ambiguous callable selection.""" + native = NATIVE.replace("Backend *makeBackend() { throw 7; }", "Backend &makeBackend(); Service &makeBackend();") + result = analysis(tmp_path, sources(tmp_path, native=native)) + assert not sites(result, "context_binding") + assert not sites(result, "context_access") + + +def test_req_qml018_ac03_const_reference_retains_original_declared_api_shape(tmp_path): + """Static declared surface retains CV without proving a runtime QObject conversion.""" + result = analysis(tmp_path, sources(tmp_path, native=NATIVE.replace("Backend *makeBackend()", "const Backend &makeBackend()"))) + member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend") + metadata = qt_metadata(member) + assert metadata["generic_target_id"] + assert metadata["api_type_spelling"] == "const Backend &" + assert metadata["api_type_status"] == "resolved" + assert len(sites(result, "context_binding")) == 1 + assert sites(result, "context_access") and len(sites(result, "context_subscription")) == 2 diff --git a/tests/test_extract.py b/tests/test_extract.py index ef3b10b21d..1117790998 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -468,7 +468,7 @@ def test_collect_files_from_dir(): from graphify.extract import _DISPATCH files = collect_files(FIXTURES) supported = set(_DISPATCH.keys()) - assert all(f.suffix in supported for f in files) + assert all(f.suffix in supported or f.name in {"qmldir", "CMakeLists.txt"} for f in files) assert len(files) > 0 @@ -545,7 +545,11 @@ def _legacy_collect_files(target, *, root=None): def test_collect_files_parity_with_legacy_on_fixtures(): - assert collect_files(FIXTURES) == _legacy_collect_files(FIXTURES) + files = collect_files(FIXTURES) + # The extension-only legacy oracle predates exact-name QML metadata. + assert [path for path in files if path.name not in {"qmldir", "CMakeLists.txt"}] == _legacy_collect_files(FIXTURES) + assert [path for path in files if path.name == "qmldir"] == [ + FIXTURES / "qml" / "parser_probe" / "qmldir"] def test_collect_files_parity_with_legacy_synthetic(tmp_path): @@ -5416,8 +5420,11 @@ def test_python_external_calls_survive_real_incremental_context(tmp_path): (tmp_path / "ext_b.py").write_text("import requests as rq\n\ndef fetch_b():\n return rq.post('/b')\n") (tmp_path / "helper.py").write_text("def other():\n return 1\n") (tmp_path / "local_caller.py").write_text("import helper\n\ndef fetch_local():\n return helper.get()\n") + # The native subprocess needs Windows home/system fields; HOME alone is + # POSIX-only. Inherit sandboxed paths while keeping provider state excluded. env = {k: v for k, v in os.environ.items() - if k in {"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "PYTHONDONTWRITEBYTECODE"}} + if k in {"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "PYTHONDONTWRITEBYTECODE", + "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "SYSTEMROOT", "TEMP", "TMP"}} env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1]) def run(): diff --git a/tests/test_filesystem_profiles.py b/tests/test_filesystem_profiles.py new file mode 100644 index 0000000000..009bface16 --- /dev/null +++ b/tests/test_filesystem_profiles.py @@ -0,0 +1,203 @@ +"""Portable admission/rebuild proof with narrow OS failure seams. + +Real FIFO, socket, symlink and removed-CWD fixtures remain in their original +modules. These tests run on Windows too, protecting the production handling of +stat results and CWD lookup failures without claiming native POSIX object proof. +""" +from __future__ import annotations + +import builtins +import errno +import io +import json +import os +import stat +from pathlib import Path + +import pytest + +from graphify.detect import _is_regular_file, detect +from graphify.watch import _rebuild_code + + +@pytest.mark.parametrize( + "mode", + [stat.S_IFIFO, stat.S_IFSOCK, stat.S_IFCHR, stat.S_IFBLK, stat.S_IFDIR], + ids=["fifo", "socket", "character-device", "block-device", "directory"], +) +def test_req_core_002_ac01_detect_rejects_nonregular_stat_modes(tmp_path, monkeypatch, mode): + """Admission rejects a special-file stat mode before any source reader runs.""" + candidate = tmp_path / "unsafe.py" + candidate.write_text("def unsafe(): pass\n", encoding="utf-8") + safe = tmp_path / "safe.py" + safe.write_text("def safe(): pass\n", encoding="utf-8") + real_stat = os.stat + observed = [] + reads = [] + + # Substitute only this source's native stat result. Discovery, admission, + # classification and reporting still use the actual production functions. + def special_stat(path, *args, **kwargs): + result = real_stat(path, *args, **kwargs) + if Path(path) == candidate: + observed.append(path) + fields = list(result) + fields[0] = mode | stat.S_IRUSR + return os.stat_result(fields) + return result + + monkeypatch.setattr(os, "stat", special_stat) + # Watch both builtin and pathlib readers: a caught reader error must not + # conceal an attempted read of the source classified as nonregular. + def guarded_reader(reader): + def read(path, *args, **kwargs): + if not isinstance(path, int) and Path(path) == candidate: + reads.append(path) + raise AssertionError("nonregular source reached a reader") + return reader(path, *args, **kwargs) + return read + + monkeypatch.setattr(builtins, "open", guarded_reader(builtins.open)) + monkeypatch.setattr(io, "open", guarded_reader(io.open)) + assert _is_regular_file(candidate) is False + result = detect(tmp_path) + assert observed + assert str(candidate) not in result["files"]["code"] + assert str(safe) in result["files"]["code"] + assert str(candidate) + " [not a regular file]" in result["skipped_sensitive"] + assert reads == [] + + +@pytest.mark.parametrize("error_code", [errno.EACCES, errno.ENOENT, errno.ELOOP, errno.EIO]) +def test_req_core_002_ac01_stat_failures_are_unreadable(tmp_path, monkeypatch, error_code): + """Access failure, removal race, link loop and I/O failure reject the source.""" + candidate = tmp_path / "unreadable.py" + candidate.write_text("def unreadable(): pass\n", encoding="utf-8") + real_stat = os.stat + calls = [] + + # The source exists; only its owning stat boundary fails. Any other path, + # including pytest's own artifacts, keeps the native filesystem behavior. + def failed_stat(path, *args, **kwargs): + if Path(path) == candidate: + calls.append(path) + raise OSError(error_code, os.strerror(error_code), str(candidate)) + return real_stat(path, *args, **kwargs) + + monkeypatch.setattr(os, "stat", failed_stat) + assert _is_regular_file(candidate) is False + assert calls == [candidate] + + +def _fail_cwd_lookup(monkeypatch, unavailable): + """Fail only cwd lookup in one real directory, preserving recovery chdir.""" + real_cwd = Path.cwd + + def lookup(cls): + cwd = real_cwd() + if cwd == unavailable: + raise FileNotFoundError(errno.ENOENT, "current working directory was removed") + return cwd + + monkeypatch.setattr(Path, "cwd", classmethod(lookup)) + + +@pytest.mark.parametrize("root_kind", ["unset", "missing", "chdir-denied"]) +def test_req_core_002_ac02_unavailable_cwd_rejects_before_artifacts( + tmp_path, monkeypatch, capsys, root_kind +): + """A failed CWD lookup retains prior graph bytes and creates no queue or lock.""" + unavailable = tmp_path / "unavailable" + unavailable.mkdir() + out = unavailable / "graphify-out" + out.mkdir() + graph = out / "graph.json" + prior = b'{"nodes": [], "retained": true}\n' + graph.write_bytes(prior) + if root_kind == "missing": + monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(tmp_path / "missing")) + elif root_kind == "chdir-denied": + denied = tmp_path / "denied" + denied.mkdir() + monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(denied)) + real_chdir = os.chdir + + def failed_chdir(path): + if Path(path) == denied: + raise PermissionError(errno.EACCES, "repository root cannot be entered") + real_chdir(path) + + monkeypatch.setattr(os, "chdir", failed_chdir) + else: + monkeypatch.delenv("GRAPHIFY_REPO_ROOT", raising=False) + monkeypatch.chdir(unavailable) + _fail_cwd_lookup(monkeypatch, unavailable) + + assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")]) is False + assert graph.read_bytes() == prior + assert {path.name for path in out.iterdir()} == {"graph.json"} + assert "current working directory no longer exists" in capsys.readouterr().out + + +def test_req_core_002_ac02_repo_root_recovers_cwd_and_publishes_graph(tmp_path, monkeypatch): + """Real chdir recovery reaches extraction, persistence and lock/queue cleanup.""" + unavailable = tmp_path / "unavailable" + unavailable.mkdir() + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "lib.py").write_text("def recovered(): pass\n", encoding="utf-8") + monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(corpus)) + monkeypatch.chdir(unavailable) + _fail_cwd_lookup(monkeypatch, unavailable) + with pytest.raises(FileNotFoundError): + Path.cwd() + + assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")], no_cluster=True) is True + assert Path.cwd() == corpus + out = corpus / "graphify-out" + persisted = json.loads((out / "graph.json").read_text(encoding="utf-8")) + assert any(node["label"] == "recovered()" for node in persisted["nodes"]) + assert not (out / ".pending_changes").exists() + assert not (out / ".rebuild.lock").exists() + assert not (unavailable / "graphify-out").exists() + + +@pytest.mark.parametrize( + ("root_kind", "reason"), + [ + ("unset", "GRAPHIFY_REPO_ROOT is not set"), + ("missing", "GRAPHIFY_REPO_ROOT does not name an available directory"), + ("chdir-denied", "GRAPHIFY_REPO_ROOT could not be entered"), + ], +) +def test_req_core_002_ac03_unavailable_cwd_reports_root_reason( + tmp_path, monkeypatch, capsys, root_kind, reason +): + """Failure diagnostics identify root setup or entry failure without path leaks.""" + unavailable = tmp_path / "unavailable" + unavailable.mkdir() + supplied_root = tmp_path / "private-repository-root" + if root_kind == "unset": + monkeypatch.delenv("GRAPHIFY_REPO_ROOT", raising=False) + else: + monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(supplied_root)) + if root_kind == "chdir-denied": + supplied_root.mkdir() + real_chdir = os.chdir + + def denied_chdir(path): + if Path(path) == supplied_root: + raise PermissionError(errno.EACCES, "private failure detail", str(supplied_root)) + real_chdir(path) + + monkeypatch.setattr(os, "chdir", denied_chdir) + monkeypatch.chdir(unavailable) + _fail_cwd_lookup(monkeypatch, unavailable) + + assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")]) is False + output = capsys.readouterr().out + assert "current working directory no longer exists" in output + assert reason in output + assert str(supplied_root) not in output + assert "private failure detail" not in output + assert not (unavailable / "graphify-out").exists() diff --git a/tests/test_hooks.py b/tests/test_hooks.py index a4f04bccef..09cb951ff3 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest from graphify.hooks import install, uninstall, status, _hooks_dir, _HOOK_MARKER, _CHECKOUT_MARKER +from tests.shell_portability import resolve_shell_path, select_shell_executable def _make_git_repo(tmp_path: Path) -> Path: @@ -717,7 +718,7 @@ def _detect_run(tmp_path, home, stub_bin, env_extra=None): env.update(env_extra) env["PATH"] = str(stub_bin) + os.pathsep + env["PATH"] return subprocess.run( - ["sh", script.name], capture_output=True, text=True, + [select_shell_executable("sh"), script.name], capture_output=True, text=True, cwd=str(tmp_path), env=env, ) @@ -769,8 +770,12 @@ def test_uv_tool_env_rescues_hook_when_pin_and_launcher_fail(tmp_path): mine = _tool_venv(home, "graphifyy", "bin/python", ok=True) res = _detect_run(tmp_path, home, stub_bin) assert res.returncode == 0, res.stderr - assert f"RESOLVED={mine}" in res.stdout, res.stdout + res.stderr - assert f"RESOLVED={other}" not in res.stdout + # MSYS reports its mount spelling; compare the complete native file identity + # and retain rejection of the earlier same-basename sibling tool environment. + assert res.stdout.startswith("RESOLVED="), res.stdout + res.stderr + resolved = resolve_shell_path(res.stdout.removeprefix("RESOLVED=").strip()) + assert resolved == mine.resolve() + assert resolved != other.resolve() assert "could not locate" not in res.stderr @@ -823,7 +828,12 @@ def test_shebang_parse_requires_leading_hash_bang(tmp_path): launcher.chmod(0o755) res = _detect_run(tmp_path, home, stub_bin) assert res.returncode == 0, res.stderr - assert f"RESOLVED={mine}" in res.stdout, res.stdout + res.stderr + # The successful fallback must be this exact tool env, never the launcher + # decoy, even when the shell maps its full path through an MSYS mount. + assert res.stdout.startswith("RESOLVED="), res.stdout + res.stderr + resolved = resolve_shell_path(res.stdout.removeprefix("RESOLVED=").strip()) + assert resolved == mine.resolve() + assert resolved != decoy.resolve() assert "fakepy" not in res.stdout @@ -879,10 +889,10 @@ def _shell_verdict(pattern: str, candidate: str, tmp_path) -> str: encoding="utf-8", newline="\n", ) - # Invoke by bare filename from cwd: a Windows absolute path in argv would - # hit the same backslash mangling the script contents just avoided. + # Keep the script basename in argv to preserve Windows payload bytes; + # launch the admitted absolute shell identity to avoid native shadowing. result = subprocess.run( - ["bash", script.name], + [select_shell_executable("bash"), script.name], capture_output=True, text=True, cwd=str(tmp_path), ) # Fail loudly on a malformed case snippet instead of returning "" and @@ -974,7 +984,7 @@ def test_shell_verdict_delivers_the_payload_bash_unmodified(payload, tmp_path): encoding="utf-8", newline="\n", ) result = subprocess.run( - ["bash", script.name], capture_output=True, text=True, cwd=str(tmp_path), + [select_shell_executable("bash"), script.name], capture_output=True, text=True, cwd=str(tmp_path), ) assert result.returncode == 0, result.stderr assert result.stdout == payload, ( @@ -1017,7 +1027,7 @@ def test_checkout_hook_skips_same_head_noop_at_runtime(): def run(prev, new, flag): # sh -c CMD name arg1 arg2 arg3 -> $0=name $1=prev $2=new $3=flag - return subprocess.run(["sh", "-c", prefix, "hook", prev, new, flag], + return subprocess.run([select_shell_executable("sh"), "-c", prefix, "hook", prev, new, flag], capture_output=True, text=True) # branch switch (flag=1), SAME head -> short-circuit, sentinel not reached @@ -1067,9 +1077,9 @@ def _git(*args, cwd): _git("worktree", "add", "-q", str(linked), "-b", "feature", cwd=primary) snippet = _worktree_guard_snippet() - r_primary = subprocess.run(["sh", "-c", snippet], cwd=primary, + r_primary = subprocess.run([select_shell_executable("sh"), "-c", snippet], cwd=primary, capture_output=True, text=True) - r_linked = subprocess.run(["sh", "-c", snippet], cwd=linked, + r_linked = subprocess.run([select_shell_executable("sh"), "-c", snippet], cwd=linked, capture_output=True, text=True) assert "RAN" in r_primary.stdout, "guard wrongly skipped the primary checkout" assert "RAN" not in r_linked.stdout, "guard failed to skip the linked worktree" @@ -1307,7 +1317,7 @@ def test_baked_viz_limit_yields_to_an_explicit_per_run_override(tmp_path): # prove the shell semantics: an explicit env value survives the export line line = 'export GRAPHIFY_VIZ_NODE_LIMIT="${GRAPHIFY_VIZ_NODE_LIMIT:-100}"' out = subprocess.run( - ["sh", "-c", f'GRAPHIFY_VIZ_NODE_LIMIT=7; {line}; echo "$GRAPHIFY_VIZ_NODE_LIMIT"'], + [select_shell_executable("sh"), "-c", f'GRAPHIFY_VIZ_NODE_LIMIT=7; {line}; echo "$GRAPHIFY_VIZ_NODE_LIMIT"'], capture_output=True, text=True, check=True, ) assert out.stdout.strip() == "7" @@ -1506,7 +1516,7 @@ def _emitted_hook_run(repo: Path, script: str, args: list[str], env_extra: dict[ env.pop(key, None) env.update(env_extra) return subprocess.run( - ["sh", str(hook), *args], capture_output=True, text=True, cwd=str(repo), env=env, + [select_shell_executable("sh"), str(hook), *args], capture_output=True, text=True, cwd=str(repo), env=env, ) diff --git a/tests/test_install.py b/tests/test_install.py index 49a3bba32f..e52d4d8947 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1435,7 +1435,7 @@ def test_hermes_skill_destination_posix_uses_home(): from graphify.__main__ import _platform_skill_destination with patch("graphify.__main__.platform.system", return_value="Linux"): dst = _platform_skill_destination("hermes", project=False) - assert str(dst).endswith(".hermes/skills/graphify/SKILL.md"), dst + assert dst == Path.home() / ".hermes" / "skills" / "graphify" / "SKILL.md", dst def _cli_dispatched_commands() -> set[str]: diff --git a/tests/test_install_references.py b/tests/test_install_references.py index ed7090cca4..3e825c9cdb 100644 --- a/tests/test_install_references.py +++ b/tests/test_install_references.py @@ -295,7 +295,8 @@ def test_claude_install_ships_lean_core_and_references(tmp_path): ] -def test_gemini_install_references_all_resolve(tmp_path): +@pytest.mark.parametrize("system", ["Linux", "Windows"]) +def test_gemini_install_references_all_resolve(tmp_path, monkeypatch, system): """End-to-end: every references/ pointer in gemini's installed SKILL.md resolves. gemini ships claude's lean skill.md body but resolves its references through a @@ -303,8 +304,12 @@ def test_gemini_install_references_all_resolve(tmp_path): real claude bundle must leave no dead pointer on disk. """ import re + # Gemini's user scope is shared .agents on Windows and .gemini on POSIX; + # both destinations must retain every real references/ pointer. + monkeypatch.setattr(mainmod.platform, "system", lambda: system) _install(tmp_path, "gemini") - skill = tmp_path / ".gemini" / "skills" / "graphify" / "SKILL.md" + dot_dir = ".agents" if system == "Windows" else ".gemini" + skill = tmp_path / dot_dir / "skills" / "graphify" / "SKILL.md" assert skill.exists() refdir = skill.parent / "references" assert refdir.is_dir() diff --git a/tests/test_install_roundtrip.py b/tests/test_install_roundtrip.py index 68216e6b3b..2038500c83 100644 --- a/tests/test_install_roundtrip.py +++ b/tests/test_install_roundtrip.py @@ -57,6 +57,9 @@ def test_skill_roundtrip_at_real_destination(platform, project, tmp_path, monkey home.mkdir() project_dir.mkdir() monkeypatch.chdir(project_dir) + # Hermes owns LOCALAPPDATA on Windows. Keep that native destination in the + # same isolated user profile as Path.home instead of the outer home fixture. + monkeypatch.setenv("LOCALAPPDATA", str(home / "AppData" / "Local")) with patch("graphify.__main__.Path.home", return_value=home): dst = mainmod._platform_skill_destination( @@ -65,9 +68,11 @@ def test_skill_roundtrip_at_real_destination(platform, project, tmp_path, monkey # Sanity: a user-scope install must not write under the project dir, and # vice versa, so the two scopes never collide in this test. if project: - assert str(dst).startswith(str(project_dir)) + assert dst.is_relative_to(project_dir) else: - assert str(dst).startswith(str(home)) + assert dst.is_relative_to(home) + if platform == "hermes" and mainmod.platform.system() == "Windows": + assert dst == home / "AppData" / "Local" / "hermes" / "skills" / "graphify" / "SKILL.md" returned = mainmod._copy_skill_file( platform, project=project, project_dir=project_dir diff --git a/tests/test_languages.py b/tests/test_languages.py index 09c3aba3cf..3ebe72c4bb 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1,4 +1,4 @@ -"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, Fortran, JS/TS, .NET project files, XAML, Robot Framework.""" +"""Language extractor contracts, including the optional Qt 6 QML source profile.""" from __future__ import annotations from pathlib import Path import pytest @@ -5247,3 +5247,23 @@ def test_kotlin_bracketed_annotations_emits_multiple_attribute_edges(tmp_path): refs = _edge_labels(result, "references", "attribute") assert ("Foo", "Inject") in refs assert ("Foo", "VisibleForTesting") in refs + + +def test_qml_language_facade_preserves_source_declarations_and_spans(): + """REQ-QML-001/003: the optional language facade keeps real QML source facts.""" + from graphify.extract import extract_qml + from graphify.qml_resolution_types import qml_metadata + source = FIXTURES / "qml/parser_probe/Main.qml" + result = extract_qml(source, root=source.parent) + if any(item.get("code") == "QML_PARSER_UNAVAILABLE" for item in result.get("diagnostics", [])): + pytest.skip("QML parser not installed (optional [qml] extra)") + assert not result.get("error"), result.get("diagnostics") + original = source.read_bytes() + expected = {("signal", "activated"): b"signal activated(int value, string label)", + ("function", "bump"): b"function bump(step: int): int"} + for (kind, name), prefix in expected.items(): + matches = [qml_metadata(node) for node in result["nodes"] + if qml_metadata(node).get("kind") == kind and qml_metadata(node).get("raw_name") == name] + assert len(matches) == 1 + span = matches[0]["span"] + assert original[span["start_byte"]:span["end_byte"]].startswith(prefix) diff --git a/tests/test_non_regular_files.py b/tests/test_non_regular_files.py index 13bc85e738..d436f066f6 100644 --- a/tests/test_non_regular_files.py +++ b/tests/test_non_regular_files.py @@ -34,6 +34,7 @@ def test_regular_source_file_is_accepted(tree): assert _is_regular_file(tree / "src" / "module.py") is True +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="native Python has no FIFO creation API") def test_fifo_is_rejected(tree): """The shape that hangs the whole run.""" fifo = tree / "src" / "pipe.py" @@ -42,6 +43,7 @@ def test_fifo_is_rejected(tree): assert _is_regular_file(fifo) is False +@pytest.mark.skipif(not hasattr(socket, "AF_UNIX"), reason="native Python has no AF_UNIX socket API") def test_unix_socket_is_rejected(tree): sock_path = tree / "src" / "sock.py" sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -58,14 +60,15 @@ def test_directory_named_like_a_source_file_is_rejected(tree): assert _is_regular_file(d) is False -def test_symlink_to_a_regular_file_is_accepted(tree): +def test_symlink_to_a_regular_file_is_accepted(tree, requires_symlinks): target = tree / "src" / "module.py" link = tree / "src" / "alias.py" link.symlink_to(target) assert _is_regular_file(link) is True -def test_symlink_pointing_at_a_fifo_is_rejected(tree): +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="native Python has no FIFO creation API") +def test_symlink_pointing_at_a_fifo_is_rejected(tree, requires_symlinks): """A link to a FIFO blocks exactly like the FIFO, so stat must follow it.""" fifo = tree / "src" / "real.py" os.mkfifo(fifo) @@ -74,7 +77,7 @@ def test_symlink_pointing_at_a_fifo_is_rejected(tree): assert _is_regular_file(link) is False -def test_broken_symlink_is_rejected_without_raising(tree): +def test_broken_symlink_is_rejected_without_raising(tree, requires_symlinks): link = tree / "src" / "dangling.py" link.symlink_to(tree / "src" / "does-not-exist.py") assert _is_regular_file(link) is False diff --git a/tests/test_publication.py b/tests/test_publication.py new file mode 100644 index 0000000000..66dec42c98 --- /dev/null +++ b/tests/test_publication.py @@ -0,0 +1,297 @@ +"""INC-QML-25: durable stages, rollback authority and truthful cleanup outcomes.""" +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import stat + +import pytest + +import graphify.publication as publication +from graphify.paths import os_replace_with_fallback +from graphify.publication import ProductPublication + + +NAMES = ("graph.json", "manifest.json", ".graphify_root", ".qt_analysis.json") + + +def seed(root, existing=True): + root.mkdir(exist_ok=True) + if existing: + for name in NAMES: + (root / name).write_bytes(("OLD:" + name).encode()) + return {name: (root / name).read_bytes() if existing else None for name in NAMES} + + +def contents(root): + return {name: (root / name).read_bytes() if (root / name).exists() else None for name in NAMES} + + +def stages(transaction): + for name in NAMES: + transaction.stage(name).write_bytes(("NEW:" + name).encode()) + + +@pytest.mark.parametrize("existing", [True, False]) +@pytest.mark.parametrize("phase", ["prepare", "before_replace", "after_replace"]) +def test_inc25_actual_disk_rollback_and_retry(tmp_path, existing, phase): + """A serializer/late replacement failure retains all old or absent products.""" + before = seed(tmp_path, existing) + + def replace(source, destination): + if destination.name == "manifest.json" and phase == "before_replace": + raise OSError("replace boundary failed") + os_replace_with_fallback(source, destination) + if destination.name == "manifest.json" and phase == "after_replace": + raise OSError("replace completed before external failure") + + with pytest.raises(OSError): + with ProductPublication(tmp_path, NAMES, replace=replace) as transaction: + stages(transaction) + if phase == "prepare": + raise OSError("serializer failed") + transaction.commit() + assert contents(tmp_path) == before + assert not list(tmp_path.glob(".gfy-publish-*")) + with ProductPublication(tmp_path, NAMES) as transaction: + stages(transaction) + assert transaction.commit() == NAMES + after = contents(tmp_path) + with ProductPublication(tmp_path, NAMES) as transaction: + stages(transaction) + assert transaction.commit() == () + assert contents(tmp_path) == after + assert not list(tmp_path.glob(".gfy-publish-*")) + + +@pytest.mark.parametrize("error", [OSError, MemoryError, ValueError]) +def test_inc25_actual_rollback_fault_retains_recovery_copies(tmp_path, monkeypatch, error): + """A second actual restoration fault is distinct from fully preserved failure.""" + before = seed(tmp_path) + + def fail_second(source, destination): + if destination.name == "manifest.json": + raise OSError("publication failure") + os_replace_with_fallback(source, destination) + + def rollback_fault(*_): + raise error("restoration failure") + + monkeypatch.setattr(publication, "os_replace_with_fallback", rollback_fault) + with pytest.raises(OSError, match="GRAPH_PUBLICATION_RECOVERY"): + with ProductPublication(tmp_path, NAMES, replace=fail_second) as transaction: + stages(transaction) + transaction.commit() + assert (tmp_path / "graph.json").read_bytes() == b"NEW:graph.json" + retained = list(tmp_path.glob(".gfy-publish-*")) + assert len(retained) == 1 + assert {name: (retained[0] / "old" / name).read_bytes() for name in NAMES} == before + # Recovery uses retained authoritative bytes, rather than discarding them + # or treating the partly published candidate as a successful cohort. + for name in NAMES: + os_replace_with_fallback(retained[0] / "old" / name, tmp_path / name) + assert contents(tmp_path) == before + + +def test_inc25_real_replace_fallback_failure_restores_cohort(tmp_path, monkeypatch): + """The shared copy/rename fallback fails late and restores already published siblings.""" + before = seed(tmp_path) + original_copy = shutil.copy2 + + def unavailable(*_): + raise PermissionError("shared-folder replace unavailable") + + def failed_fallback(source, target, *args, **kwargs): + start, end = Path(source), Path(target) + if start.name == "manifest.json" and start.parent.name == "stage" and end.name.startswith(".gfy-replace-"): + raise OSError("fallback copy failed") + return original_copy(source, target, *args, **kwargs) + + with monkeypatch.context() as failure: + failure.setattr(os, "replace", unavailable) + failure.setattr(shutil, "copy2", failed_fallback) + with pytest.raises(OSError, match="GRAPH_PUBLICATION_FAILED"): + with ProductPublication(tmp_path, NAMES) as transaction: + stages(transaction) + transaction.commit() + assert contents(tmp_path) == before + assert {path.name for path in tmp_path.iterdir()} == set(NAMES) + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute") +def test_inc25_unchanged_readonly_products_skip_replacement(tmp_path): + """Readonly siblings are valid when their exact accepted bytes need no write.""" + seed(tmp_path) + target = tmp_path / "manifest.json" + target.chmod(stat.S_IREAD) + try: + with ProductPublication(tmp_path, NAMES) as transaction: + transaction.stage("graph.json").write_bytes(b"NEW") + transaction.stage("manifest.json") + assert transaction.commit() == ("graph.json",) + assert target.read_bytes() == b"OLD:manifest.json" + assert not target.stat().st_mode & stat.S_IWRITE + assert not list(tmp_path.glob(".gfy-publish-*")) + finally: + target.chmod(stat.S_IWRITE) + + +@pytest.mark.parametrize("change", ["changed", "appeared", "missing_stage"]) +def test_inc25_preparation_cannot_overwrite_unrelated_concurrent_change(tmp_path, change): + """All originals are validated even when a product has no changed stage.""" + seed(tmp_path, existing=change != "appeared") + with pytest.raises(OSError, match="GRAPH_PUBLICATION_FAILED"): + with ProductPublication(tmp_path, NAMES) as transaction: + stages(transaction) + if change == "missing_stage": + transaction.stage("manifest.json").unlink() + else: + (tmp_path / "manifest.json").write_bytes(b"FOREIGN") + transaction.commit() + if change != "appeared": + assert (tmp_path / "graph.json").read_bytes() == b"OLD:graph.json" + else: + assert not (tmp_path / "graph.json").exists() + if change != "missing_stage": + assert (tmp_path / "manifest.json").read_bytes() == b"FOREIGN" + assert not list(tmp_path.glob(".gfy-publish-*")) + + +@pytest.mark.parametrize("committed", [False, True]) +@pytest.mark.parametrize("error", [OSError, MemoryError]) +def test_inc25_cleanup_failure_does_not_misreport_cohort_authority(tmp_path, monkeypatch, capsys, committed, error): + """Only coherent committed products may be reported successful after cleanup faults.""" + before = seed(tmp_path) + original = ProductPublication._cleanup + + def cleanup_fault(_): + raise error("owned scratch is locked") + + monkeypatch.setattr(ProductPublication, "_cleanup", cleanup_fault) + if committed: + with ProductPublication(tmp_path, NAMES) as transaction: + stages(transaction) + transaction.commit() + assert "GRAPH_PUBLICATION_CLEANUP: committed products retained" in capsys.readouterr().err + assert contents(tmp_path) != before + else: + with pytest.raises(OSError, match="GRAPH_PUBLICATION_CLEANUP: prior products retained"): + with ProductPublication(tmp_path, NAMES) as transaction: + stages(transaction) + raise OSError("serializer failure") + assert contents(tmp_path) == before + assert len(list(tmp_path.glob(".gfy-publish-*"))) == 1 + original(transaction) + assert not list(tmp_path.glob(".gfy-publish-*")) + + +def test_inc25_symlink_target_write_through_and_duplicate_authority(tmp_path): + """Caller symlinks retain identity; two product roles cannot share a real target.""" + actual = tmp_path / "actual.json" + actual.write_bytes(b"OLD") + output = tmp_path / "out" + output.mkdir() + try: + (output / "graph.json").symlink_to(actual) + (output / "manifest.json").symlink_to(actual) + except OSError: + pytest.skip("Host symlink creation is unavailable") + with pytest.raises(ValueError, match="share one destination"): + ProductPublication(output, ("graph.json", "manifest.json")) + with ProductPublication(output, ("graph.json",)) as transaction: + transaction.stage("graph.json").write_bytes(b"NEW") + transaction.commit() + assert actual.read_bytes() == b"NEW" + assert (output / "graph.json").is_symlink() + assert not list(output.glob(".gfy-publish-*")) + + +@pytest.mark.parametrize("names", [("../outside",), ("graph.json", "graph.json"), (".",), ("",), + ("graph.json:foreign",), ("a" * 129,), (None,)]) +def test_inc25_inventory_is_bounded_to_owned_product_names(tmp_path, names): + """Invalid product roles reject before any scratch or destination writes.""" + with pytest.raises(ValueError, match="GRAPH_PUBLICATION_FAILED"): + ProductPublication(tmp_path, names) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("phase", ["output_dir", "scratch_dir", "child_dir", "snapshot", "canonical_path"]) +def test_inc25_setup_fault_has_safe_code_and_preserves_retry(tmp_path, monkeypatch, phase): + """External setup/copy rejection never exposes the exception's path or body.""" + before = seed(tmp_path) + private_marker = "synthetic-sensitive-location" + error = PermissionError(13, private_marker + "\nserializer body", str(tmp_path / private_marker)) + mkdir, copy = Path.mkdir, shutil.copy2 + + def failed_mkdir(path, *args, **kwargs): + if ((phase == "output_dir" and path == tmp_path) + or (phase == "child_dir" and path.name == "old" + and path.parent.name.startswith(".gfy-publish-"))): + raise error + return mkdir(path, *args, **kwargs) + + def failed_copy(source, target, *args, **kwargs): + if phase == "snapshot" and Path(target).name == "manifest.json": + raise error + return copy(source, target, *args, **kwargs) + + def failed_scratch(*args, **kwargs): + raise error + + with monkeypatch.context() as failure: + failure.setattr(Path, "mkdir", failed_mkdir) + failure.setattr(shutil, "copy2", failed_copy) + if phase == "scratch_dir": + failure.setattr(publication.tempfile, "mkdtemp", failed_scratch) + if phase == "canonical_path": + failure.setattr(publication.os.path, "realpath", failed_scratch) + with pytest.raises(OSError, match="GRAPH_PUBLICATION_FAILED") as rejected: + ProductPublication(tmp_path, NAMES) + assert private_marker not in str(rejected.value) + assert str(tmp_path) not in str(rejected.value) + assert rejected.value.__cause__ is error + assert contents(tmp_path) == before + assert not list(tmp_path.glob(".gfy-publish-*")) + with ProductPublication(tmp_path, NAMES) as transaction: + stages(transaction) + assert transaction.commit() == NAMES + assert contents(tmp_path) != before + assert not list(tmp_path.glob(".gfy-publish-*")) + + +def test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence(tmp_path, monkeypatch): + """Failed scaffold cleanup reports its own safe code and keeps accepted bytes.""" + before = seed(tmp_path) + error = PermissionError(13, "synthetic-sensitive-location", str(tmp_path / "private-fixture")) + copy, unlink = shutil.copy2, Path.unlink + rejected_cleanup = [] + + def reject_copy(source, target, *args, **kwargs): + if Path(target).parent.name == "old" and Path(target).name == "manifest.json": + raise error + return copy(source, target, *args, **kwargs) + + # Patch the owning Path method: Python 3.10 caches its os.unlink accessor. + # Witness only this transaction's recovery copy, preserving all others. + def reject_cleanup(path, *args, **kwargs): + target = Path(path) + if (target.parent.name == "old" and target.name == "graph.json" + and target.parent.parent.name.startswith(".gfy-publish-") + and target.parent.parent.parent == tmp_path): + rejected_cleanup.append(target) + raise error + return unlink(path, *args, **kwargs) + + with monkeypatch.context() as failure: + failure.setattr(shutil, "copy2", reject_copy) + failure.setattr(Path, "unlink", reject_cleanup) + with pytest.raises(OSError, match="GRAPH_PUBLICATION_CLEANUP") as rejected: + ProductPublication(tmp_path, NAMES) + assert "synthetic-sensitive-location" not in str(rejected.value) + assert str(tmp_path) not in str(rejected.value) + assert contents(tmp_path) == before + retained = list(tmp_path.glob(".gfy-publish-*")) + assert len(retained) == 1 and (retained[0] / "old/graph.json").read_bytes() == before["graph.json"] + assert rejected_cleanup and set(rejected_cleanup) == {retained[0] / "old/graph.json"} + assert rejected.value.__cause__ is error diff --git a/tests/test_qml_adversarial.py b/tests/test_qml_adversarial.py new file mode 100644 index 0000000000..497d9d38d2 --- /dev/null +++ b/tests/test_qml_adversarial.py @@ -0,0 +1,198 @@ +"""Adversarial production-boundary checks for scoped QML/JS relationship facts.""" + +from __future__ import annotations + +import base64 +import copy +import json + +import pytest + +from graphify.extract import extract_js +from graphify.extractors.qml_ast import parse_source +from graphify.extractors.qml_declarations import Declarations +from graphify.extractors.qml_expressions import collect_relationships +from graphify.extractors.qml_facts import FactBuilder, qml_metadata +from graphify.extractors.qml_scripts import collect_qml_scripts +from graphify.qml_relationships import resolve_qml_relationships +from graphify.security import sanitize_metadata + + +def _facts(root, source, scripts=None): + """Exercise the real AST collectors before projection, with no fixture evaluator.""" + path = root / "Main.qml" + path.write_text(source, encoding="utf-8") + raw, program, _ = parse_source(path) + facts = FactBuilder(path, root, raw) + declarations = Declarations(facts) + declarations.extract(program) + collect_relationships(declarations) + per_file = {path: {"nodes": facts.nodes, "edges": facts.edges}} + for name, text in (scripts or {}).items(): + script = root / name + script.parent.mkdir(parents=True, exist_ok=True) + script.write_text(text, encoding="utf-8") + per_file[script] = extract_js(script) + collect_qml_scripts(list(per_file), list(per_file.values()), root=root) + return per_file + + +def _resolve(root, per_file): + nodes = [node for result in per_file.values() for node in result["nodes"]] + edges = [edge for result in per_file.values() for edge in result["edges"]] + resolve_qml_relationships(per_file, nodes, edges, root=root) + return nodes, edges + + +def _sites(nodes, kind, reference): + return [node for node in nodes if qml_metadata(node).get("kind") == kind and + qml_metadata(node).get("reference") == reference] + + +@pytest.mark.parametrize("loop", [ + "for (let target = 0; target < 1; target++) { target(); }", + "for (const target of [1]) { target(); }", + "for (let target in {a: 1}) { target(); }", +]) +def test_loop_local_bindings_shadow_qml_only_inside_loop(tmp_path, loop): + """Loop declarations must prevent false same-name QML calls without leaking out.""" + source = "QtObject { function target() {} function run() { " + loop + " target(); } }" + nodes, edges = _resolve(tmp_path, _facts(tmp_path, source)) + sites = sorted(_sites(nodes, "call", "target"), key=lambda node: qml_metadata(node)["span"]["start_byte"]) + assert len(sites) == 2 + assert qml_metadata(sites[0])["status"] == "dynamic" + assert qml_metadata(sites[0])["reason"] == "javascript_lexical_binding" + assert not any(edge["source"] == sites[0]["id"] and edge["relation"] == "calls" for edge in edges) + assert qml_metadata(sites[1])["status"] == "resolved" + + +def test_catch_binding_and_arrow_parameter_do_not_capture_outer_qml_function(tmp_path): + """Catch/arrow binders shadow; a sibling call still reaches the QML function.""" + source = """QtObject { + function target() {} + function run() { + try { throw 1; } catch (target) { target(); } + const closure = (target) => target(); + target(); + } + }""" + nodes, _ = _resolve(tmp_path, _facts(tmp_path, source)) + sites = sorted(_sites(nodes, "call", "target"), key=lambda node: qml_metadata(node)["span"]["start_byte"]) + assert [qml_metadata(site)["status"] for site in sites] == ["dynamic", "dynamic", "resolved"] + + +def test_for_var_binding_is_function_scoped_and_reassigned_callable_is_dynamic(tmp_path): + """Function-wide var and changed local callable values cannot fall back to QML.""" + source = """QtObject { function target() {} function run() { + for (var target of [1]) { target(); } target(); + let chosen = () => 1; chosen = () => 2; chosen(); + } }""" + nodes, edges = _resolve(tmp_path, _facts(tmp_path, source)) + sites = _sites(nodes, "call", "target") + _sites(nodes, "call", "chosen") + assert len(sites) == 3 + assert all(qml_metadata(site)["status"] != "resolved" for site in sites) + assert not any(edge["source"] in {site["id"] for site in sites} and edge["relation"] == "calls" for edge in edges) + + +def test_assignment_target_is_not_read_but_computed_address_reads_are_retained(tmp_path): + """Writes differ from dependency reads, including receiver/index evaluation.""" + source = """QtObject { id: root; property var store: ({}); property int index: 0; + property int value: 1; + function run() { value = 2; store[index] = value; value += index; } + }""" + per_file = _facts(tmp_path, source) + nodes, _ = _resolve(tmp_path, per_file) + reads = [qml_metadata(node)["reference"] for node in nodes if qml_metadata(node).get("kind") == "read"] + assert reads.count("value") == 2 + assert reads.count("index") == 2 + assert reads.count("store") == 1 + + +def test_many_lexical_bindings_survive_sanitized_json_without_false_resolution(tmp_path): + """Transport caps must never turn a proven local binding into a QML reference.""" + bindings = ", ".join(f"a{i:02} = 0" for i in range(60)) + ", target = 0" + per_file = _facts(tmp_path, "QtObject { function target() {} function run() { let " + bindings + "; target(); } }") + copied = json.loads(json.dumps(per_file[next(iter(per_file))])) + for node in copied["nodes"]: + node["metadata"] = sanitize_metadata(node["metadata"]) + nodes, edges = _resolve(tmp_path, {tmp_path / "Main.qml": copied}) + site = _sites(nodes, "call", "target")[0] + assert qml_metadata(site)["status"] != "resolved" + assert not any(edge["source"] == site["id"] and edge["relation"] == "calls" for edge in edges) + + +def test_exported_arrow_helper_and_local_reexport_have_correct_visibility(tmp_path): + """ESM export arrows are callable, while re-export clauses do not export locals.""" + source = 'import "helpers.mjs" as Helpers\nQtObject { function run() { Helpers.arrow(); Helpers.local(); } }' + scripts = {"helpers.mjs": "export const arrow = () => 42; function local() { return 1; } export { local } from './other.mjs';", + "other.mjs": "export function local() { return 2; }"} + nodes, edges = _resolve(tmp_path, _facts(tmp_path, source, scripts)) + arrow = _sites(nodes, "call", "Helpers.arrow")[0] + assert qml_metadata(arrow)["status"] == "resolved" + local = _sites(nodes, "call", "Helpers.local")[0] + assert qml_metadata(local)["status"] != "resolved" + assert not any(edge["source"] == local["id"] and edge["relation"] == "calls" for edge in edges) + + +@pytest.mark.parametrize("library", ["", ".pragma library\n"]) +def test_shared_script_never_binds_to_qml_importer_context(tmp_path, library): + """A shared helper has no arbitrary document's ID/function namespace.""" + source = 'import "shared.js" as Shared\nQtObject { function target() {} function run() { Shared.invoke(); } }' + nodes, edges = _resolve(tmp_path, _facts(tmp_path, source, {"shared.js": library + "function invoke() { target(); }"})) + helper = _sites(nodes, "call", "target")[0] + assert qml_metadata(helper)["status"] != "resolved" + assert not any(edge["source"] == helper["id"] and edge["relation"] == "calls" for edge in edges) + + +def test_alias_cycle_and_dynamic_connections_have_no_guessed_endpoints(tmp_path): + """Static alias cycles and runtime target selections remain explicit coverage gaps.""" + source = """QtObject { id: root; property alias first: root.second; + property alias second: root.first; property bool choose: true; + signal activated(); function run() { first; } + Connections { target: choose ? root : null; function onActivated() { root.run(); } } + }""" + nodes, edges = _resolve(tmp_path, _facts(tmp_path, source)) + aliases = [node for node in nodes if qml_metadata(node).get("kind") == "alias"] + assert aliases and all(qml_metadata(node)["reason"] == "alias_cycle_or_limit" for node in aliases) + handler = [node for node in nodes if qml_metadata(node).get("kind") == "handler"][0] + assert qml_metadata(handler)["reason"] == "connections_runtime_target" + assert not any(edge["source"] == handler["id"] and edge.get("context") == "qml_signal_subscription" for edge in edges) + + +def test_explicit_component_body_id_cannot_escape_into_enclosing_document(tmp_path): + """Qt Component's definition body is a separate ID scope from its declaring object.""" + source = """import QtQml as Qml + import QtQuick as Quick + Quick.Item { id: root; + Qml.Component { id: factory; Qml.QtObject { id: hidden; property int value: 1 } } + function run() { hidden.value; } + }""" + nodes, edges = _resolve(tmp_path, _facts(tmp_path, source)) + site = _sites(nodes, "read", "hidden.value")[0] + assert qml_metadata(site)["status"] != "resolved" + assert not any(edge["source"] == site["id"] and edge.get("context") == "qml_binding_read" for edge in edges) + + +def test_mixed_legacy_connections_handlers_do_not_activate_ignored_function_handlers(tmp_path): + """Qt ignores function-style handlers when one legacy handler exists in that object.""" + source = """QtObject { id: root; signal first(); signal second(); + Connections { target: root; onFirst: {} + function onSecond() {} + } + }""" + nodes, edges = _resolve(tmp_path, _facts(tmp_path, source)) + handlers = [node for node in nodes if qml_metadata(node).get("kind") == "handler"] + first = next(node for node in handlers if qml_metadata(node)["reference"] == "first") + second = next(node for node in handlers if qml_metadata(node)["reference"] == "second") + assert qml_metadata(first)["status"] == "resolved" + assert qml_metadata(second)["status"] != "resolved" + assert not any(edge["source"] == second["id"] and edge.get("context") == "qml_signal_subscription" for edge in edges) + + +@pytest.mark.parametrize("raw", ["not-a-map", {"raw_name": "!not-base64"}, + {"raw_name": base64.b64encode(b"x" * 500).decode()}]) +def test_corrupt_semantic_transport_is_bounded_rejection(raw): + """Malformed/bypassed field transport must reject rather than reinterpret a fact.""" + node = {"metadata": {"qml": {"contract_version": 1, "raw_name": "safe", "raw_values": copy.deepcopy(raw)}}} + with pytest.raises(ValueError): + qml_metadata(node) diff --git a/tests/test_qml_cli_persistence.py b/tests/test_qml_cli_persistence.py new file mode 100644 index 0000000000..f12b3bdb18 --- /dev/null +++ b/tests/test_qml_cli_persistence.py @@ -0,0 +1,94 @@ +"""Production CLI QML rejection, real graph retention, retry and scope refresh.""" +import json + +import pytest + +import graphify.__main__ as mainmod +import graphify.extract as ex +from tests.qml_persistence_helpers import failing_qml, healthy_qml, make_project, protected_snapshot + + +def _run(monkeypatch, project, *, no_cluster=True, flags=()): + argv = ["graphify", "extract", str(project), "--code-only", *flags] + if no_cluster: + argv.append("--no-cluster") + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", argv) + try: + mainmod.main() + except SystemExit as exc: + return exc.code + return 0 + + +@pytest.mark.parametrize("kind", ["missing", "incompatible", "read", "partial"]) +@pytest.mark.parametrize("no_cluster", [False, True]) +@pytest.mark.parametrize("extra_nodes", [0, 8], ids=["equal-count-edge-loss", "larger-candidate"]) +def test_cli_qml_failure_preserves_graph_and_manifest_despite_override( + tmp_path, monkeypatch, capsys, kind, no_cluster, extra_nodes, +): + """QML-001/002/011: completed products survive error even at equal/higher counts.""" + project = make_project(tmp_path) + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _run(monkeypatch, project) == 0 + out = project / "graphify-out" + before = protected_snapshot(out) + assert "manifest.json" in before + graph = json.loads(before["graph.json"]) + assert graph.get("links", graph.get("edges")) + (project / "Main.qml").write_text("Item { property int changed: 2 }\n", encoding="utf-8") + monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml(kind, extra_nodes=extra_nodes)) + + assert _run(monkeypatch, project, no_cluster=no_cluster, flags=("--force", "--allow-partial")) == 1 + assert protected_snapshot(out) == before + assert "QML_GRAPH_PRESERVED" in capsys.readouterr().err + + +def test_cli_failed_first_scan_publishes_no_graph_or_manifest(tmp_path, monkeypatch): + """An unavailable QML parser cannot publish a successful Python-only graph.""" + project = make_project(tmp_path) + monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml("missing")) + assert _run(monkeypatch, project, flags=("--allow-partial",)) == 1 + out = project / "graphify-out" + assert not (out / "graph.json").exists() + assert not (out / "manifest.json").exists() + + +def test_cli_retry_uses_changed_qml_and_settles_after_recovery(tmp_path, monkeypatch): + """Retaining the prior manifest leaves failed source edits pending for retry.""" + project = make_project(tmp_path) + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _run(monkeypatch, project) == 0 + source = project / "Main.qml" + source.write_text("import QtQuick\nItem { property int count: 2 }\n", encoding="utf-8") + monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml("partial")) + before = protected_snapshot(project / "graphify-out") + assert _run(monkeypatch, project) == 1 + assert protected_snapshot(project / "graphify-out") == before + + calls = [] + def recovered(path): + calls.append(path.name) + return healthy_qml(path) + monkeypatch.setitem(ex._DISPATCH, ".qml", recovered) + assert _run(monkeypatch, project) == 0 + assert calls == ["Main.qml"] + calls.clear() + assert _run(monkeypatch, project) == 0 + assert calls == [] + + +def test_cli_qml_change_refreshes_unchanged_code_facts(tmp_path, monkeypatch): + """QML-011 initial policy supplies the whole live code corpus to resolution.""" + project = make_project(tmp_path) + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _run(monkeypatch, project) == 0 + (project / "Main.qml").write_text("Item { property int changed: 1 }\n", encoding="utf-8") + called = [] + real = ex.extract + def record(paths, **kwargs): + called.extend(p.name for p in paths) + return real(paths, **kwargs) + monkeypatch.setattr(ex, "extract", record) + assert _run(monkeypatch, project) == 0 + assert set(called) == {"Main.qml", "keep.py"} diff --git a/tests/test_qml_cpp_access.py b/tests/test_qml_cpp_access.py new file mode 100644 index 0000000000..d15f3eac31 --- /dev/null +++ b/tests/test_qml_cpp_access.py @@ -0,0 +1,87 @@ +"""QML-017 literal loaders, scoped handles and objectName rejection boundaries.""" +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_analysis_helpers import analysis, sites + +QML = '''import QtQml +QtObject { + id: root + property string title: "hello" + function refresh() {} + signal closed() + property QtObject child: QtObject { id: childId; objectName: "details"; property int count: 1 } +} +''' + + +def test_view_root_and_literal_object_name_property_access(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use() {{ + QQuickView view; + view.setSource(QUrl("{url}")); + auto root = view.rootObject(); + root->property("title"); + auto child = root->findChild("details"); + child->setProperty("count", 2); + QMetaObject::invokeMethod(root, "refresh"); +}}''' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML}) + assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved" + assert qt_metadata(sites(result, "qml_root")[0])["status"] == "resolved" + accesses = sites(result, "qml_access") + assert len(accesses) == 4 + assert all(qt_metadata(node)["status"] == "resolved" for node in accesses), [qt_metadata(node) for node in accesses] + + +def test_qml_id_is_not_object_name_and_computed_lookup_not_guessed(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use(QString name) {{ QQuickView view; view.setSource(QUrl("{url}")); + auto root = view.rootObject(); root->findChild("childId"); root->findChild(name); }}''' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML}) + first, second = sites(result, "qml_access") + assert qt_metadata(first)["reason"] == "object_name_unavailable" + assert qt_metadata(second)["status"] == "dynamic" + + +def test_relative_runtime_url_and_reassigned_handle_remain_unresolved(tmp_path): + source = '''void use() { QQuickView view; view.setSource(QUrl("Main.qml")); + auto root = view.rootObject(); root = other(); root->property("title"); }''' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML}) + assert qt_metadata(sites(result, "qml_load")[0])["status"] != "resolved" + assert qt_metadata(sites(result, "qml_access")[0])["status"] != "resolved" + + +def test_duplicate_object_names_do_not_select_first_child(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + qml = QML.replace('property int count: 1 }', 'property int count: 1 }\n property QtObject other: QtObject { objectName: "details" }') + source = f'''void use() {{ QQuickView view; view.setSource(QUrl("{url}")); auto root = view.rootObject(); root->findChild("details"); }}''' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml}) + assert qt_metadata(sites(result, "qml_access")[0])["status"] == "ambiguous" + + +def test_engine_module_loader_root_selection_and_plain_component_create(tmp_path): + source = '''void use() { QQmlApplicationEngine engine; engine.loadFromModule("App", "Main"); + auto root = engine.rootObjects().first(); root->property("title"); }''' + result = analysis(tmp_path, {"access.cpp": source, "App/Main.qml": QML, "App/qmldir": "module App\nMain 1.0 Main.qml\n"}) + assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved" + assert qt_metadata(sites(result, "qml_root")[0])["status"] == "resolved" + assert qt_metadata(sites(result, "qml_access")[0])["status"] == "resolved" + opaque = analysis(tmp_path, {"access.cpp": source.replace('.first()', '.last()'), "App/Main.qml": QML, "App/qmldir": "module App\nMain 1.0 Main.qml\n"}) + assert qt_metadata(sites(opaque, "qml_root")[0])["status"] == "unsupported" + url = (tmp_path / "Main.qml").as_uri() + component = f'''void use() {{ QQmlEngine engine; QQmlComponent component(&engine, QUrl("{url}")); + auto root = component.create(); root->property("title"); }}''' + created = analysis(tmp_path, {"access.cpp": component, "Main.qml": QML}) + assert qt_metadata(sites(created, "qml_root")[0])["status"] == "resolved" + assert qt_metadata(sites(created, "qml_access")[0])["status"] == "resolved" + + +def test_literal_names_are_unicode_and_comments_templates_are_not_evidence(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + qml = QML.replace('"details"', '"détail"') + source = f'''// view.setSource(QUrl("{url}")); +void use() {{ QQuickView view; view.setSource(QUrl("{url}")); auto root = view.rootObject(); + root->findChild("détail"); const char *text = R"(root->property("title"))"; }}''' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml}) + assert len(sites(result, "qml_load")) == 1 + assert len(sites(result, "qml_access")) == 1 + assert qt_metadata(sites(result, "qml_access")[0])["status"] == "resolved" diff --git a/tests/test_qml_declarations.py b/tests/test_qml_declarations.py new file mode 100644 index 0000000000..7fdb9f6318 --- /dev/null +++ b/tests/test_qml_declarations.py @@ -0,0 +1,131 @@ +"""QML-01 declarations, source evidence and rejection at the production boundary.""" +from __future__ import annotations + +from collections import Counter + +import pytest + +from graphify.extractors.qml import extract_qml +from graphify.validate import validate_extraction +from tests.qml_test_helpers import DECLARATIONS, DECLARATION_KINDS, assert_contains, assert_spans, by_kind, canonical, one, qml, write_qml + + +def test_qml003_ac01_exact_declarations_ownership_and_raw_types(tmp_path): + """Members/imports belong to source owners; no body-derived guesses are emitted.""" + path = write_qml(tmp_path) + result = extract_qml(path, root=tmp_path) + assert not result.get("error") + assert Counter(qml(node)["kind"] for node in result["nodes"] if qml(node)["kind"] in DECLARATION_KINDS) == { + "file": 1, "component": 1, "object": 2, "property": 6, + "signal": 1, "function": 1, "import": 4, + } + assert validate_extraction(result) == [] + file_node, component = one(result, "file"), one(result, "component") + assert qml(component)["raw_name"] == "Main" + objects = {qml(node)["object_id"]: node for node in by_kind(result, "object")} + assert set(objects) == {"root", "editor"} + assert qml(objects["root"])["type_name"] == "Item" + assert qml(objects["editor"])["type_name"] == "Controls.TextField" + assert_contains(result, file_node, component) + assert_contains(result, component, objects["root"]) + assert_contains(result, objects["root"], objects["editor"]) + root_properties = {"count": "int", "title": "string", "doubled": "int", "extras": "list", "editorText": "alias"} + for name, raw_type in root_properties.items(): + node = one(result, "property", name) + assert qml(node)["raw_type"] == raw_type + assert qml(node)["parent_scope_key"] == qml(objects["root"])["object_scope_key"] + assert_contains(result, objects["root"], node) + assert_contains(result, objects["editor"], one(result, "property", "caption")) + for kind, name in (("signal", "activated"), ("function", "bump")): + assert_contains(result, objects["root"], one(result, kind, name)) + for name, modifier in (("title", "required"), ("doubled", "readonly"), ("extras", "default")): + assert modifier in qml(one(result, "property", name))["modifiers"] + assert not result.get("raw_calls") + # QML-03 may add independently tested semantic sites/edges; this QML-01 test + # remains exact about declarations and their source-backed ownership. + assert any(edge["relation"] == "contains" for edge in result["edges"]) + assert all(edge["confidence"] == "EXTRACTED" for edge in result["edges"] if edge["relation"] == "contains") + assert all(qml(node)["contract_version"] == 1 for node in result["nodes"]) + assert_spans(result, path.read_bytes()) + + +def test_qml01_import_facts_preserve_kind_value_qualifier_and_versions(tmp_path): + """Raw import facts retain evidence without invented module/component targets.""" + result = extract_qml(write_qml(tmp_path), root=tmp_path) + imports = by_kind(result, "import") + expected = [("module", "QtQuick", None, None, None), ("module", "QtQuick.Controls", "Controls", 6, 5), ("directory", "widgets", "Local", None, None), ("script", "helpers.js", "Helpers", None, None)] + actual = [tuple(qml(node).get(key) for key in ("import_kind", "value", "qualifier", "major", "minor")) for node in imports] + assert actual == expected + for node in imports: + assert_contains(result, one(result, "file"), node) + + +def test_qml003_ac03_comments_literals_groups_and_js_inner_functions_are_not_objects(tmp_path): + """Grouped properties have their own kind; fake and nested-JS declarations stay opaque.""" + source = '''import QtQuick +Item { + property string example: "Rectangle { id: fake }" + /* Button { id: phantom } */ + function calculate() { function inner() { return 3; } return inner(); } + anchors { left: parent.left; topMargin: 4 } + Text { id: label; font { pixelSize: 12; bold: true } } +} +''' + result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path) + assert not result.get("error") + assert [qml(node)["type_name"] for node in by_kind(result, "object")] == ["Item", "Text"] + groups = by_kind(result, "property_group") + assert [qml(node)["raw_name"] for node in groups] == ["anchors", "font"] + assert all(qml(node)["raw_type"] == "" for node in groups) + assert [qml(node)["raw_name"] for node in by_kind(result, "function")] == ["calculate"] + assert len(by_kind(result, "property")) == 1 + assert not result.get("raw_calls") + + +def test_qml003_ac01_unicode_crlf_spans_and_original_names(tmp_path): + """Production facts preserve original bytes and Unicode identifiers.""" + source = 'import QtQuick\r\nItem {\r\n property string café: "雪😀"; property int après: 2\r\n}\r\n' + path = write_qml(tmp_path, source=source) + result = extract_qml(path, root=tmp_path) + assert not result.get("error") + assert [qml(node)["raw_name"] for node in by_kind(result, "property")] == ["café", "après"] + assert_spans(result, path.read_bytes()) + span = qml(one(result, "property", "après"))["span"] + prefix = source.encode("utf-8").split(b"\r\n")[2][:span["start_column"]] + assert span["start_column"] == len(prefix) > len(prefix.decode("utf-8")) + + +def test_qml003_ac04_repeated_valid_extraction_is_identical(tmp_path): + """Same production invocation retains node, edge, evidence and diagnostic facts.""" + path = write_qml(tmp_path) + assert canonical(extract_qml(path, root=tmp_path)) == canonical(extract_qml(path, root=tmp_path)) + + +@pytest.mark.parametrize("source", ['Item { property int kept: 1; function unfinished() {', 'Item { property int broken: @@@; property string kept: "safe" }']) +def test_qml012_ac01_malformed_or_partial_parse_has_no_authoritative_nodes(tmp_path, source): + """Recovery cannot publish earlier declarations as a complete successful result.""" + result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path) + assert result.get("error") + assert result["nodes"] == [] and result["edges"] == [] + assert result.get("diagnostics") + assert all(str(tmp_path) not in str(diagnostic) for diagnostic in result["diagnostics"]) + + +def test_qml001_ac04_empty_source_is_distinguishable_from_parse_failure(tmp_path): + """Empty input retains its file fact and explicit informational coverage status.""" + result = extract_qml(write_qml(tmp_path, source=""), root=tmp_path) + assert not result.get("error") and len(result["nodes"]) == 1 + file_node = one(result, "file") + assert qml(file_node)["empty"] is True and result["edges"] == [] + assert any(diagnostic["code"] == "QML_EMPTY" for diagnostic in result["diagnostics"]) + assert qml(file_node)["span"]["start_byte"] == qml(file_node)["span"]["end_byte"] == 0 + + +def test_qml003_ac01_source_slices_match_hand_checked_declarations(tmp_path): + """Reported spans enclose the named declaration, not an unrelated member/body.""" + path = write_qml(tmp_path) + result = extract_qml(path, root=tmp_path) + source = DECLARATIONS.encode("utf-8") + for kind, name, prefix in (("property", "count", b"property int count"), ("property", "title", b"required property string title"), ("signal", "activated", b"signal activated"), ("function", "bump", b"function bump")): + span = qml(one(result, kind, name))["span"] + assert source[span["start_byte"]:span["end_byte"]].startswith(prefix) diff --git a/tests/test_qml_expressions.py b/tests/test_qml_expressions.py new file mode 100644 index 0000000000..855908ab31 --- /dev/null +++ b/tests/test_qml_expressions.py @@ -0,0 +1,92 @@ +"""QML-006-AC01..04 static source reads, aliases, rejection and lossless sites.""" + +import json + +import networkx as nx + +from graphify.build import build_from_json +from graphify.extractors.qml_facts import qml_metadata +from tests.qml_expression_helpers import extraction, nodes, single, target_edges + + +def test_binding_reads_and_nested_qualified_aliases(tmp_path): + result = extraction(tmp_path, {"Main.qml": """QtObject { + id: root + property int total: child.value + child.value + property alias selected: child + property alias nested: root.selected.value + property QtObject childObject: QtObject { id: child; property int value: 3 } +}"""}) + value = single(result, "property", "value") + repeated = nodes(result, "read", "child.value") + assert len(repeated) == 2 + assert len({node["id"] for node in repeated}) == 2 + for site in repeated: + assert target_edges(result, site)[0]["target"] == value["id"] + assert qml_metadata(site)["span"]["end_byte"] > qml_metadata(site)["span"]["start_byte"] + nested = single(result, "alias", "nested") + assert target_edges(result, nested, "qml_alias_target")[0]["target"] == value["id"] + assert qml_metadata(nested)["evidence"] + + +def test_alias_cycles_missing_and_ambiguous_targets_never_guess(tmp_path): + result = extraction(tmp_path, {"Main.qml": """QtObject { + id: root + property alias a: root.b + property alias b: root.a + property alias missing: unknown.value + property alias duplicate: root.value + property int value: 1 + property int value: 2 +}"""}) + for name, status in [("a", "unsupported"), ("b", "unsupported"), + ("missing", "unavailable"), ("duplicate", "ambiguous")]: + site = single(result, "alias", name) + assert qml_metadata(site)["status"] == status + assert qml_metadata(site)["reason"] + assert not target_edges(result, site) + + +def test_component_and_object_scope_do_not_bind_hidden_identifiers(tmp_path): + result = extraction(tmp_path, {"Main.qml": """QtObject { + id: root + property int value: 1 + property int total: child.value + property QtObject childObject: QtObject { id: child; property int value: 2 } + component Inner: QtObject { property int hidden: child.value } +}"""}) + reads = nodes(result, "read", "child.value") + assert len(reads) == 2 + assert sorted(qml_metadata(site)["status"] for site in reads) == ["resolved", "unavailable"] + resolved = [site for site in reads if qml_metadata(site)["status"] == "resolved"][0] + assert target_edges(result, resolved)[0]["target"] in {n["id"] for n in nodes(result, "property", "value")} + hidden = [site for site in reads if qml_metadata(site)["status"] != "resolved"][0] + assert not target_edges(result, hidden) + + +def test_dynamic_reads_and_executable_source_are_only_analyzed(tmp_path): + marker = tmp_path / "must-not-exist" + result = extraction(tmp_path, {"Main.qml": f"""QtObject {{ + property int value: provider()[key] + function provider() {{ return {{}}; }} + function dangerous() {{ require('fs').writeFileSync('{marker.as_posix()}', 'bad'); }} +}}"""}) + dynamic = [site for site in nodes(result, "read") if qml_metadata(site)["status"] == "dynamic"] + assert dynamic and all(not target_edges(result, site) for site in dynamic) + assert nodes(result, "call", "provider") + assert not marker.exists() + + +def test_repeated_sites_survive_actual_directed_build_and_json(tmp_path): + result = extraction(tmp_path, {"Main.qml": "QtObject { id: root; property int value: 1; property int sum: value + value }"}) + sites = nodes(result, "read", "value") + graph = build_from_json(result, directed=True, root=tmp_path) + assert isinstance(graph, nx.DiGraph) + value = single(result, "property", "value") + for site in sites: + assert graph.has_edge(site["id"], value["id"]) + portable = nx.node_link_graph(json.loads(json.dumps(nx.node_link_data(graph)))) + assert len(sites) == 2 + for site in sites: + assert portable.has_edge(site["id"], value["id"]) + assert qml_metadata(portable.nodes[site["id"]])["span"] == qml_metadata(site)["span"] diff --git a/tests/test_qml_failures.py b/tests/test_qml_failures.py new file mode 100644 index 0000000000..81c9e6be8d --- /dev/null +++ b/tests/test_qml_failures.py @@ -0,0 +1,98 @@ +"""QML-01 rejection and optional/native parser boundaries without unsafe facts.""" +from __future__ import annotations + +import builtins +import json + +import pytest +import tree_sitter_language_pack + +from graphify.extract import extract_python +from graphify.extractors.qml import extract_qml +from tests.qml_test_helpers import assert_no_machine_paths, write_qml + + +def assert_rejected(result, code, root): + """Rejected source cannot masquerade as a cacheable authoritative extraction.""" + assert result.get("error") + assert result["nodes"] == [] and result["edges"] == [] + assert any(diagnostic["code"] == code for diagnostic in result["diagnostics"]) + assert_no_machine_paths(result, root) + + +def test_qml001_ac03_missing_optional_import_is_safe_and_does_not_break_python(tmp_path, monkeypatch): + """Actual optional import rejection produces QML diagnostics; unrelated parsing works.""" + original_import = builtins.__import__ + + def without_qml_parser(name, *args, **kwargs): + if name == "tree_sitter_language_pack": + raise ModuleNotFoundError("Synthetic unavailable optional parser") + return original_import(name, *args, **kwargs) + + path = write_qml(tmp_path) + python_path = tmp_path / "safe.py" + python_path.write_text("def safe():\n return 1\n", encoding="utf-8") + monkeypatch.setattr(builtins, "__import__", without_qml_parser) + result = extract_qml(path, root=tmp_path) + assert_rejected(result, "QML_PARSER_MISSING", tmp_path) + assert any(node["label"] == "safe()" for node in extract_python(python_path, root=tmp_path)["nodes"]) + + +def test_qml001_ac03_incompatible_parser_load_is_bounded_failure(tmp_path, monkeypatch): + """Binding/ABI load failures are rejected with safe diagnostics, not backend details.""" + def incompatible(_grammar): + raise RuntimeError("synthetic-backend-detail must not escape") + + monkeypatch.setattr(tree_sitter_language_pack, "get_parser", incompatible) + result = extract_qml(write_qml(tmp_path), root=tmp_path) + assert_rejected(result, "QML_PARSER_LOAD", tmp_path) + assert "synthetic-backend-detail" not in json.dumps(result) + + +def test_qml001_ac04_unsupported_annotated_root_is_not_file_only_success(tmp_path): + """A grammar-recognized shape outside the adapter profile needs explicit rejection.""" + path = write_qml(tmp_path, source="@Unknown {} Item { property int retained: 1 }\n") + # The grammar accepts this shape; the declaration adapter must own its coverage + # decision instead of publishing a successful file with all declarations lost. + assert not tree_sitter_language_pack.get_parser("qmljs").parse(path.read_bytes()).root_node.has_error + result = extract_qml(path, root=tmp_path) + assert result.get("error") and result.get("diagnostics") + assert result["nodes"] == [] and result["edges"] == [] + + +def test_qml012_ac01_native_parse_exception_is_explicit_safe_failure(tmp_path, monkeypatch): + """A failing external parser cannot escape or publish private backend exception text.""" + class FailingParser: + def parse(self, _source): + raise RuntimeError("synthetic-backend-detail must not escape") + + monkeypatch.setattr(tree_sitter_language_pack, "get_parser", lambda _grammar: FailingParser()) + result = extract_qml(write_qml(tmp_path), root=tmp_path) + assert result.get("error") and result.get("diagnostics") + assert result["nodes"] == [] and result["edges"] == [] + assert "synthetic-backend-detail" not in json.dumps(result) + + +@pytest.mark.parametrize("source", [b"Item { property string value: '\xff' }", b"//" + b"a" * 5_000_001], ids=["invalid-utf8", "oversized"]) +def test_qml012_ac04_unreadable_or_oversized_source_has_safe_failure(tmp_path, source): + """Decode/size limits reject input before publishing any partial declarations.""" + path = tmp_path / "Main.qml" + path.write_bytes(source) + result = extract_qml(path, root=tmp_path) + assert_rejected(result, "QML_READ" if b"\xff" in source else "QML_LIMIT", tmp_path) + + +def test_qml012_ac04_deep_source_terminates_at_supported_bound(tmp_path): + """Deep but grammar-valid objects do not recurse through unbounded extraction.""" + result = extract_qml(write_qml(tmp_path, source="Item {" * 270 + "}" * 270), root=tmp_path) + assert_rejected(result, "QML_LIMIT", tmp_path) + + +def test_qml012_ac04_explicit_root_rejection_does_not_expose_absolute_paths(tmp_path): + """An out-of-corpus file is rejected without serializing a machine-specific path.""" + allowed, outside = tmp_path / "allowed", tmp_path / "outside" + allowed.mkdir() + result = extract_qml(write_qml(outside), root=allowed) + assert result.get("error") and result["nodes"] == [] + assert_no_machine_paths(result, outside) + assert_no_machine_paths(result, allowed) diff --git a/tests/test_qml_graph.py b/tests/test_qml_graph.py new file mode 100644 index 0000000000..acbbf6786e --- /dev/null +++ b/tests/test_qml_graph.py @@ -0,0 +1,71 @@ +"""QML-01 mixed-language, graph projection and spawned-worker boundaries.""" +from __future__ import annotations + +from concurrent.futures import ProcessPoolExecutor +import json +import multiprocessing + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import _extract_single_file, extract +from graphify.extractors.qml import extract_qml +from graphify.paths import load_node_link_graph +from graphify.validate import validate_extraction +from tests.qml_test_helpers import DECLARATION_KINDS, by_kind, canonical, qml, write_qml + + +def test_qml010_ac02_directed_build_and_reload_keep_all_ownership_and_qml_metadata(tmp_path): + """Existing graph assembly preserves distinct source-backed declarations and edges.""" + extraction = extract_qml(write_qml(tmp_path), root=tmp_path) + graph = build_from_json(extraction, directed=True, root=tmp_path) + assert graph.is_directed() + assert set(graph) == {node["id"] for node in extraction["nodes"]} + expected = {(edge["source"], edge["target"], edge["relation"]) for edge in extraction["edges"]} + assert {(source, target, data["relation"]) for source, target, data in graph.edges(data=True)} == expected + target = tmp_path / "graph.json" + assert to_json(graph, {}, str(target)) is True + serialized = json.loads(target.read_text(encoding="utf-8")) + restored = load_node_link_graph(serialized) + assert set(restored) == set(graph) + assert all(restored.nodes[node["id"]]["metadata"]["qml"] == node["metadata"]["qml"] for node in extraction["nodes"]) + assert all(restored.edges[source, target]["confidence"] == edge["confidence"] for edge in extraction["edges"] for source, target in [(edge["source"], edge["target"])]) + + +def test_qml003_ac02_same_stem_cpp_js_and_qml_do_not_merge(tmp_path): + """Generic same-stem producers cannot overwrite hashed QML file/member ownership.""" + qml_path = write_qml(tmp_path, "Panel.qml", "Item { property int value: 1; function run() { return value; } }\n") + cpp_path, js_path = tmp_path / "Panel.cpp", tmp_path / "Panel.js" + cpp_path.write_text("int run() { return 1; }\n", encoding="utf-8") + js_path.write_text("export function run() { return 2; }\n", encoding="utf-8") + direct = extract_qml(qml_path, root=tmp_path) + batch = extract([qml_path, cpp_path, js_path], cache_root=tmp_path / "cache", root=tmp_path, parallel=False) + qml_nodes = [node for node in batch["nodes"] if node.get("metadata", {}).get("qml", {}).get("kind") in DECLARATION_KINDS] + direct_nodes = [node for node in direct["nodes"] if qml(node)["kind"] in DECLARATION_KINDS] + assert len(qml_nodes) == len(direct_nodes) + assert {node["id"]: qml(node) for node in qml_nodes} == {node["id"]: qml(node) for node in direct_nodes} + assert {node["source_file"] for node in batch["nodes"]} >= {"Panel.qml", "Panel.cpp", "Panel.js"} + assert validate_extraction(batch) == [] + + +def test_qml003_ac04_batch_filesystem_order_and_warm_cache_do_not_change_facts(tmp_path): + """Production cache/reordering cannot remove declarations or scope metadata.""" + paths = [write_qml(tmp_path, relative) for relative in ("left/Main.qml", "right/Main.qml")] + kwargs = {"cache_root": tmp_path / "cache", "root": tmp_path, "parallel": False} + cold = extract(paths, **kwargs) + warm = extract(list(reversed(paths)), **kwargs) + assert canonical(cold) == canonical(warm) + assert len(by_kind(cold, "component")) == 2 + assert len({node["id"] for node in cold["nodes"]}) == len(cold["nodes"]) + + +def test_qml003_ac04_spawned_worker_retains_explicit_root_and_scope_ids(tmp_path): + """A real spawn worker runs the registered production path, without fallback fakes.""" + path = write_qml(tmp_path, "ui/Main.qml") + direct = extract_qml(path, root=tmp_path) + context = multiprocessing.get_context("spawn") + work = (0, str(path), str(tmp_path), str(tmp_path / "worker-cache")) + with ProcessPoolExecutor(max_workers=2, mp_context=context) as pool: + index, result = pool.submit(_extract_single_file, work).result(timeout=45) + assert index == 0 and canonical(result) == canonical(direct) + assert all(node["source_file"] == "ui/Main.qml" for node in result["nodes"]) + assert len({qml(node)["component_key"] for node in by_kind(result, "object")}) == 1 diff --git a/tests/test_qml_handler_formals.py b/tests/test_qml_handler_formals.py new file mode 100644 index 0000000000..d2e5939d01 --- /dev/null +++ b/tests/test_qml_handler_formals.py @@ -0,0 +1,104 @@ +"""REQ-QML-007-AC03: handler formals own their names across production boundaries.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import extract +from graphify.extractors.qml import extract_qml +from graphify.paths import load_node_link_graph +from graphify.qml_resolution_types import qml_metadata +from tests.qt_analysis_helpers import analysis + + +def qml(style, parameters="int count"): + """Legacy injection and renamed explicit formals observe different dependencies.""" + handler = { + "legacy": "onReady: { count; }", + "arrow": "onReady: (renamed) => { count; renamed; }", + "anonymous": "onReady: function(renamed) { count; renamed; }", + "connections": "property Connections observer: Connections { target: root; " + "function onReady(renamed) { count; renamed; } }", + }[style] + return "\ufeff// café\r\nimport QtQml\r\nQtObject { id: root; property int count: 1; " + \ + f"signal ready({parameters}); {handler} }}\r\n" + + +@pytest.mark.parametrize("style", ["legacy", "arrow", "anonymous", "connections"]) +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml007_ac03_source_handler_formals_preserve_property_dependency(tmp_path, style, directed): + """Direct facts carry authority; facade, build and reload retain exact bindings.""" + source = qml(style) + result = analysis(tmp_path, {"Main.qml": source}) + direct = extract_qml(tmp_path / "Main.qml", root=tmp_path) + handler = next(node for node in direct["nodes"] if qml_metadata(node).get("kind") == "handler") + md = qml_metadata(handler) + assert md["implicit_parameters"] is (style == "legacy") + span = md["span"] + original = source.encode()[span["start_byte"]:span["end_byte"]] + assert original.startswith(b"function onReady" if style == "connections" else b"onReady") + assert handler["source_file"] == "Main.qml" and handler["source_location"].startswith("L3") + direct_reads = {qml_metadata(node)["reference"]: qml_metadata(node) for node in direct["nodes"] + if qml_metadata(node).get("kind") == "read"} + assert direct_reads["count"]["lexical_shadowed"] is (style == "legacy") + if style != "legacy": + assert direct_reads["renamed"]["lexical_shadowed"] is True + graph = build_from_json(result, root=tmp_path, directed=directed) + output = tmp_path / "handler.json" + assert to_json(graph, {}, str(output), force=True) + restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + reads = {qml_metadata(node)["reference"]: (identity, qml_metadata(node)) + for identity, node in restored.nodes(data=True) if qml_metadata(node).get("kind") == "read"} + identity, count = reads["count"] + assert count["status"] == ("dynamic" if style == "legacy" else "resolved") + read_edges = [(a, b, data) for a, b, data in restored.edges(data=True) + if data.get("context") == "qml_binding_read" and data.get("_src", a) == identity] + assert len(read_edges) == (0 if style == "legacy" else 1) + if style != "legacy": + assert reads["renamed"][1]["status"] == "dynamic" + assert qml_metadata(restored.nodes[count["resolved_target_id"]])["raw_name"] == "count" + assert restored.has_edge(identity, count["resolved_target_id"]) + subscriptions = [data for _, _, data in restored.edges(data=True) if data.get("context") == "qml_signal_subscription"] + assert len(subscriptions) == 1 + + +@pytest.mark.parametrize("style", ["legacy", "arrow"]) +def test_req_qml007_ac03_handler_authority_survives_parameter_display_limit(tmp_path, style): + """The last supported signal parameter retains exact lexical authority.""" + names = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVW" + parameters = ", ".join([f"int {name}" for name in names] + ["int count"]) + result = analysis(tmp_path, {"Main.qml": qml(style, parameters)}) + read = next(qml_metadata(node) for node in result["nodes"] + if qml_metadata(node).get("kind") == "read" and qml_metadata(node).get("reference") == "count") + assert read["status"] == ("dynamic" if style == "legacy" else "resolved") + assert read["lexical_shadowed"] is (style == "legacy") + + +@pytest.mark.parametrize("style", ["legacy", "arrow"]) +def test_req_qml007_ac03_signal_parameter_overflow_rejects_without_truncation(tmp_path, style): + """Unsupported signatures fail closed before a hidden binder can resolve.""" + names = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX" + parameters = ", ".join([f"int {name}" for name in names] + ["int count"]) + path = tmp_path / "Main.qml" + path.write_text(qml(style, parameters), encoding="utf-8", newline="") + direct = extract_qml(path, root=tmp_path) + facade = extract([path], root=tmp_path, parallel=False) + assert direct["qml_failures"][0]["code"] == "QML_LIMIT" + assert facade["qml_failures"][0]["code"] == "QML_LIMIT" + assert not direct["nodes"] and not facade["nodes"] + + +def test_req_qml007_ac03_handler_change_does_not_reclassify_unrelated_javascript(tmp_path): + """Ordinary JavaScript retains its generic callable IDs and source metadata.""" + script = tmp_path / "keep.js" + script.write_text("function helper(count) { return count; }\n", encoding="utf-8") + before = extract([script], root=tmp_path, cache_root=tmp_path / ".before", parallel=False) + after = analysis(tmp_path, {"Main.qml": qml("arrow"), "keep.js": script.read_text(encoding="utf-8")}) + def selected(result): + return ([node for node in result["nodes"] if node.get("source_file") == "keep.js"], + [edge for edge in result["edges"] if edge.get("source_file") == "keep.js"]) + assert selected(after) == selected(before) + assert not any(qml_metadata(node) for node in selected(after)[0]) diff --git a/tests/test_qml_handlers.py b/tests/test_qml_handlers.py new file mode 100644 index 0000000000..fef7423def --- /dev/null +++ b/tests/test_qml_handlers.py @@ -0,0 +1,107 @@ +"""QML-007-AC01/03 handler subscriptions, emits and lexical JS visibility.""" + +from graphify.extractors.qml_facts import qml_metadata +from tests.qml_expression_helpers import extraction, nodes, single, target_edges + + +def test_declared_and_property_change_handlers_are_subscriptions(tmp_path): + result = extraction(tmp_path, {"Main.qml": """QtObject { + id: root + property int value: 1 + signal ping(int value) + function work(n) { return n; } + onPing: { work(value); } + onValueChanged: { work(value); } + function publish() { ping(value); } +}"""}) + signal = single(result, "signal", "ping") + handler = single(result, "handler", "onPing") + edge = target_edges(result, handler, "qml_signal_subscription")[0] + assert edge["relation"] == "references" and edge["target"] == signal["id"] + notify = single(result, "property_change_signal") + assert target_edges(result, single(result, "handler", "onValueChanged"))[0]["target"] == notify["id"] + emission = single(result, "call", "ping") + assert target_edges(result, emission, "qml_signal_emit")[0]["relation"] == "uses" + parameter_reads = [site for site in nodes(result, "read", "value") + if "value" in qml_metadata(site)["lexical_names"]] + assert len(parameter_reads) == 1 + assert qml_metadata(parameter_reads[0])["reason"] == "javascript_lexical_binding" + + +def test_connections_target_and_dynamic_target_stay_distinct(tmp_path): + result = extraction(tmp_path, {"Main.qml": """QtObject { + id: root + signal ping(int n) + function work(n) { return n; } + property QtObject connection: Connections { + target: root + function onPing(n) { work(n); } + } + property QtObject dynamicConnection: Connections { + target: chooseTarget() + onPing: { work(1); } + } +}"""}) + handlers = nodes(result, "handler", "onPing") + assert len(handlers) == 2 + fixed = [site for site in handlers if qml_metadata(site)["connection_target"] == "root"][0] + assert target_edges(result, fixed)[0]["target"] == single(result, "signal", "ping")["id"] + dynamic = [site for site in handlers if not qml_metadata(site)["connection_target"]][0] + assert qml_metadata(dynamic)["status"] == "dynamic" + assert not target_edges(result, dynamic) + + +def test_parameters_block_bindings_nested_functions_and_computed_calls(tmp_path): + result = extraction(tmp_path, {"Main.qml": """QtObject { + function work() { return 1; } + function run(work) { work(); } + function outer() { + { let work = 3; work(); } + work(); + function local() { work(); } + local(); + object[name](); + } +}"""}) + calls = nodes(result, "call", "work") + assert len(calls) == 4 + assert sorted(qml_metadata(site)["status"] for site in calls) == ["dynamic", "dynamic", "resolved", "resolved"] + local = single(result, "call", "local") + assert target_edges(result, local, "qml_js_call")[0]["target"] == single(result, "js_function", "local")["id"] + computed = single(result, "call", "dynamic") + assert qml_metadata(computed)["reason"] == "computed_or_runtime_target" + assert not target_edges(result, computed) + + +def test_inherited_and_alias_target_signal_parameters_shadow_properties(tmp_path): + result = extraction(tmp_path, {"Base.qml": "QtObject { signal ping(int value) }", + "Main.qml": """Base { + id: root + property int value: 1 + property alias chosen: child + property Base childObject: Base { id: child } + function helper(n) { return n; } + onPing: { helper(value); } + property QtObject subscription: Connections { + target: root.chosen + onPing: { helper(value); } + } +}"""}) + signal = single(result, "signal", "ping", "Base.qml") + assert qml_metadata(signal)["parameter_names"] == ["value"] + for handler in nodes(result, "handler", "onPing"): + assert target_edges(result, handler)[0]["target"] == signal["id"] + reads = nodes(result, "read", "value", "Main.qml") + assert len(reads) == 2 + assert all(qml_metadata(site)["reason"] == "javascript_lexical_binding" for site in reads) + assert all(not target_edges(result, site) for site in reads) + + +def test_signal_parameter_transport_limit_is_explicit_failure(tmp_path): + from graphify.extractors.qml import extract_qml + path = tmp_path / "Main.qml" + parameters = ", ".join(f"int n{i}" for i in range(51)) + path.write_text("QtObject { signal ping(" + parameters + ") }", encoding="utf-8") + result = extract_qml(path, root=tmp_path) + assert result["qml_failures"] == [{"code": "QML_LIMIT", "source_file": "Main.qml"}] + assert not result["nodes"] diff --git a/tests/test_qml_identity.py b/tests/test_qml_identity.py new file mode 100644 index 0000000000..74f8770d01 --- /dev/null +++ b/tests/test_qml_identity.py @@ -0,0 +1,126 @@ +"""QML-01 identity contracts: raw spelling, owner scope and explicit scan root.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.extractors.qml import extract_qml +from tests.qml_test_helpers import DECLARATION_KINDS, assert_contains, by_kind, canonical, one, qml, write_qml + + +def test_qml003_ac04_relocated_root_and_cwd_preserve_all_facts(tmp_path, monkeypatch): + """Explicit root is authoritative even when process cwd and absolute checkout change.""" + first_root, second_root, cwd = (tmp_path / name for name in ("first-checkout", "second-checkout", "elsewhere")) + first = write_qml(first_root, "ui/Main.qml") + second = write_qml(second_root, "ui/Main.qml") + cwd.mkdir() + before = extract_qml(first, root=first_root) + monkeypatch.chdir(cwd) + after = extract_qml(second, root=second_root) + assert canonical(before) == canonical(after) + assert all(node["source_file"] == "ui/Main.qml" for node in before["nodes"]) + assert all(edge["source_file"] == "ui/Main.qml" for edge in before["edges"]) + assert first_root.as_posix() not in canonical(before) + assert second_root.as_posix() not in canonical(after) + + +def test_qml003_ac04_comment_insert_changes_spans_but_not_named_identity(tmp_path): + """Moving a declaration down a line does not replace its graph identity.""" + source = 'Item { id: root; property int count: 1; function bump() { return count; } }\n' + path = write_qml(tmp_path, source=source) + before = extract_qml(path, root=tmp_path) + write_qml(tmp_path, source="// Added line only\n" + source) + after = extract_qml(path, root=tmp_path) + # Named declarations use semantic identity; expression occurrences retain + # their documented spans and may change identity when source offsets move. + assert {node["id"] for node in before["nodes"] if qml(node)["kind"] in DECLARATION_KINDS} == { + node["id"] for node in after["nodes"] if qml(node)["kind"] in DECLARATION_KINDS} + for kind, name in (("property", "count"), ("function", "bump")): + old, new = one(before, kind, name), one(after, kind, name) + assert old["id"] == new["id"] + assert qml(new)["span"]["start_row"] == qml(old)["span"]["start_row"] + 1 + assert qml(old)["component_key"] == qml(new)["component_key"] + assert qml(old)["object_scope_key"] == qml(new)["object_scope_key"] + + +def test_qml003_ac02_duplicate_basename_uses_full_relative_path(tmp_path): + """Same file/member names under different directories cannot share identities.""" + results = [extract_qml(write_qml(tmp_path, relative), root=tmp_path) for relative in ("left/widgets/Panel.qml", "right/widgets/Panel.qml")] + identities = [{node["id"] for node in result["nodes"]} for result in results] + assert identities[0].isdisjoint(identities[1]) + assert qml(one(results[0], "component"))["component_key"] != qml(one(results[1], "component"))["component_key"] + + +@pytest.mark.parametrize("first_name,second_name", [("Panel.qml", "panel.qml"), ("a-b.qml", "a_b.qml"), ("a.b.qml", "a_b.qml"), ("A.qml", "A.qml")]) +def test_qml010_ac01_filename_normalization_collisions_keep_distinct_ids(tmp_path, first_name, second_name): + """Separate roots permit case-distinct path spelling checks on Windows filesystems.""" + results = [] + for index, name in enumerate((first_name, second_name)): + root = tmp_path / f"checkout-{index}" + results.append(extract_qml(write_qml(root, name), root=root)) + assert {node["id"] for node in results[0]["nodes"]}.isdisjoint({node["id"] for node in results[1]["nodes"]}) + assert one(results[0], "file")["source_file"] == first_name + assert one(results[1], "file")["source_file"] == second_name + + +def test_qml003_ac02_unicode_normalization_colliding_members_remain_distinct(tmp_path): + """NFKC-equivalent original identifier spellings must retain separate facts.""" + source = 'Item { property int café: 1; property int cafe\u0301: 2 }\n' + result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path) + assert not result.get("error") + properties = by_kind(result, "property") + assert [qml(node)["raw_name"] for node in properties] == ["café", "cafe\u0301"] + assert len({node["id"] for node in properties}) == 2 + + +def test_qml003_ac02_inline_component_equal_ids_and_members_have_separate_owners(tmp_path): + """Equal IDs in root/inline component namespaces are not global property names.""" + source = '''Item { + id: shared + property int value: 1 + function run() { return value; } + component Tile: Item { + id: shared + property int value: 2 + function run() { return value; } + } +} +''' + result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path) + assert not result.get("error") + inline = one(result, "inline_component", "Tile") + objects = by_kind(result, "object") + assert len(objects) == 2 and all(qml(node)["object_id"] == "shared" for node in objects) + assert len({qml(node)["component_key"] for node in objects}) == 2 + assert len({qml(node)["object_scope_key"] for node in objects}) == 2 + inner = next(node for node in objects if qml(node)["component_key"] == qml(inline)["component_key"]) + assert_contains(result, inline, inner) + for kind in ("property", "function"): + members = by_kind(result, kind) + assert len(members) == 2 and len({node["id"] for node in members}) == 2 + assert {qml(node)["parent_scope_key"] for node in members} == {qml(node)["object_scope_key"] for node in objects} + assert not any(qml(node)["raw_name"] == "id" for node in by_kind(result, "property")) + + +def test_qml01_ui_suffix_names_component_without_losing_filename_identity(tmp_path): + """Designer .ui.qml exports Panel while keeping Panel.qml's file scope separate.""" + normal = extract_qml(write_qml(tmp_path, "Panel.qml"), root=tmp_path) + designer = extract_qml(write_qml(tmp_path, "Panel.ui.qml"), root=tmp_path) + assert qml(one(normal, "component"))["raw_name"] == qml(one(designer, "component"))["raw_name"] == "Panel" + assert one(designer, "file")["source_file"] == "Panel.ui.qml" + assert {node["id"] for node in normal["nodes"]}.isdisjoint({node["id"] for node in designer["nodes"]}) + + +def test_qml010_ac02_metadata_and_keys_survive_json_round_trip_without_paths(tmp_path): + """Nested scope facts are plain portable values, not Path objects or machine roots.""" + result = extract_qml(write_qml(tmp_path, "ui/Main.qml"), root=tmp_path) + encoded = json.dumps(result, ensure_ascii=False) + reloaded = json.loads(encoded) + assert canonical(reloaded) == canonical(result) + for node in reloaded["nodes"]: + data = qml(node) + for key in ("component_key", "object_scope_key", "parent_scope_key"): + value = data.get(key) + if value is not None: + assert tmp_path.as_posix() not in value and str(tmp_path) not in value diff --git a/tests/test_qml_integration.py b/tests/test_qml_integration.py new file mode 100644 index 0000000000..9232c4d2ad --- /dev/null +++ b/tests/test_qml_integration.py @@ -0,0 +1,211 @@ +"""Real QML-02 admission, project joins, workers and conservative update parity.""" +from __future__ import annotations + +import copy +import json + +import pytest + +import graphify.extract as ex +from graphify.build import build_from_json +from graphify.detect import FileType, classify_file +from graphify.export import to_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qml_metadata import extract_qmldir +from graphify.paths import load_node_link_graph +from graphify.watch import _rebuild_code +from tests.qml_test_helpers import canonical, write_qml + + +def project(root): + """Two accepted providers let a qmldir-only edit change an unchanged caller's target.""" + write_qml(root, "Public/Tools/Old.qml", "QtObject { property int oldValue: 1 }\n") + write_qml(root, "Public/Tools/New.qml", "QtObject { property int newValue: 2 }\n") + metadata = root / "Public/Tools/qmldir" + metadata.write_text("module Public.Tools\nButton 1.0 Old.qml\n", encoding="utf-8") + write_qml(root, source="import Public.Tools 1.0\nButton { id: root }\n") + return metadata + + +def type_target(result, file="Main.qml", label="Button"): + """Follow the actual source-owned resolution site to its exact provider endpoint.""" + site = [node for node in result["nodes"] if node["source_file"] == file and + qml_metadata(node).get("kind") == "type_use" and node["label"] == label] + assert len(site) == 1 and qml_metadata(site[0])["status"] == "resolved" + links = [edge for edge in result["edges"] if edge["source"] == site[0]["id"] and edge["relation"] == "uses"] + assert len(links) == 1 + return links[0]["target"] + + +def portable_graph(graph): + """Compare promised QML facts, excluding writer-owned community/timestamp fields.""" + nodes = {identity: qml_metadata(data) for identity, data in graph.nodes(data=True)} + edges = {(data.get("_src", source), data.get("_tgt", target), data["relation"], data.get("context"), data["confidence"]) for source, target, data in graph.edges(data=True)} + return nodes, edges + + +def test_qml002_ac02_named_qmldir_dispatch_directory_and_single_file_root(tmp_path): + """Extensionless metadata reaches the actual adapter with the same explicit root.""" + metadata = project(tmp_path) + assert classify_file(metadata) == FileType.CODE + assert ex._get_extractor(metadata) is extract_qmldir + assert ex.collect_files(metadata, root=tmp_path) == [metadata] + files = ex.collect_files(tmp_path, root=tmp_path) + assert metadata in files + result = ex.extract(files, root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + target = type_target(result) + assert next(node for node in result["nodes"] if node["id"] == target)["source_file"] == "Public/Tools/Old.qml" + assert not result.get("qml_failures") + + +@pytest.mark.parametrize("ignore_file", [".gitignore", ".graphifyignore"]) +def test_qml002_ac03_named_metadata_obeys_project_ignores(tmp_path, ignore_file): + """An ignored provider cannot become visible merely because the file exists on disk.""" + metadata = project(tmp_path) + (tmp_path / ignore_file).write_text("Public/Tools/qmldir\n", encoding="utf-8") + files = ex.collect_files(tmp_path, root=tmp_path) + assert metadata not in files + result = ex.extract(files, root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + sites = [node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "type_use"] + assert len(sites) == 1 and qml_metadata(sites[0])["status"] != "resolved" + assert not any(edge["source"] == sites[0]["id"] and edge["relation"] == "uses" for edge in result["edges"]) + + +def test_qml002_ac03_explicit_single_metadata_entry_obeys_root_ignore(tmp_path): + """Directory and single-file admission use the same approved root ignore policy.""" + metadata = project(tmp_path) + (tmp_path / ".graphifyignore").write_text("Public/Tools/qmldir\n", encoding="utf-8") + assert ex.collect_files(metadata, root=tmp_path) == [] + + +@pytest.mark.parametrize("follow", [False, True]) +def test_qml004_ac04_external_symlink_metadata_never_expands_corpus(tmp_path, follow): + """Even explicit symlink following cannot grant an outside module provider access.""" + root, external = tmp_path / "accepted", tmp_path / "outside" + root.mkdir() + external.mkdir() + outside = external / "qmldir" + outside.write_text("module Public.Tools\nButton 1.0 Button.qml\n", encoding="utf-8") + link = root / "qmldir" + try: + link.symlink_to(outside) + except (OSError, NotImplementedError): + pytest.skip("Host does not permit creating symlinks") + assert ex.collect_files(root, root=root, follow_symlinks=follow) == [] + assert ex.collect_files(link, root=root, follow_symlinks=follow) == [] + assert extract_qmldir(link, root=root).get("error") + + +def test_qml010_ac01_real_process_pool_and_warm_order_match_sequential(tmp_path, monkeypatch): + """More than twenty public sources exercise the actual pool, not its sequential fallback.""" + paths = [write_qml(tmp_path, f"components/Panel{index}.qml", f"Item {{ id: root; property int value: {index} }}\n") for index in range(24)] + pool_results = [] + real_pool = ex._extract_parallel + + def observe_pool(*args, **kwargs): + ran = real_pool(*args, **kwargs) + pool_results.append(ran) + return ran + + monkeypatch.setattr(ex, "_extract_parallel", observe_pool) + parallel = ex.extract(paths, root=tmp_path, cache_root=tmp_path / "pool-cache", parallel=True, max_workers=2) + assert pool_results == [True] + sequential = ex.extract(list(reversed(paths)), root=tmp_path, cache_root=tmp_path / "serial-cache", parallel=False) + warm = ex.extract(paths, root=tmp_path, cache_root=tmp_path / "serial-cache", parallel=False) + assert canonical(parallel) == canonical(sequential) == canonical(warm) + + +def test_qml004_project_join_borrows_unchanged_context_without_mutating_or_returning_it(tmp_path): + """A changed caller resolves through accepted provider context while its owners stay borrowed.""" + project(tmp_path) + paths = ex.collect_files(tmp_path, root=tmp_path) + full = ex.extract(paths, root=tmp_path, cache_root=tmp_path / "full-cache", parallel=False) + context_nodes = [node for node in full["nodes"] if node["source_file"] != "Main.qml"] + context_ids = {node["id"] for node in context_nodes} + context_edges = [edge for edge in full["edges"] if edge["source"] in context_ids and edge["target"] in context_ids] + before = copy.deepcopy((context_nodes, context_edges)) + caller = ex.extract([tmp_path / "Main.qml"], root=tmp_path, cache_root=tmp_path / "caller-cache", parallel=False, + resolution_context_nodes=context_nodes, resolution_context_edges=context_edges) + assert (context_nodes, context_edges) == before + assert all(node["source_file"] == "Main.qml" for node in caller["nodes"]) + assert type_target(caller) == type_target(full) + assert type_target(caller) in context_ids + + +def test_qml011_ac02_qmldir_only_update_refreshes_unchanged_caller_and_matches_clean_build(tmp_path, monkeypatch): + """Real update publishes the new provider, removes its old edge and rechecks the live corpus.""" + metadata = project(tmp_path) + assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False) + graph_path = tmp_path / "graphify-out/graph.json" + initial = json.loads(graph_path.read_text(encoding="utf-8")) + before_target = type_target({"nodes": initial["nodes"], "edges": initial.get("edges", initial.get("links"))}) + metadata.write_text("module Public.Tools\nButton 1.0 New.qml\n", encoding="utf-8") + called = [] + real_extract = ex.extract + + def observe_extract(paths, **kwargs): + called.extend(paths) + return real_extract(paths, **kwargs) + + monkeypatch.setattr(ex, "extract", observe_extract) + assert _rebuild_code(tmp_path, changed_paths=[metadata], no_cluster=True, acquire_lock=False) + assert tmp_path / "Main.qml" in called and metadata in called + updated = json.loads(graph_path.read_text(encoding="utf-8")) + updated_edges = updated.get("edges", updated.get("links")) + after_target = type_target({"nodes": updated["nodes"], "edges": updated_edges}) + assert after_target != before_target + assert next(node for node in updated["nodes"] if node["id"] == after_target)["source_file"] == "Public/Tools/New.qml" + clean = real_extract(ex.collect_files(tmp_path, root=tmp_path), root=tmp_path, cache_root=tmp_path / "clean-cache", parallel=False) + assert portable_graph(load_node_link_graph(updated)) == portable_graph(build_from_json(clean, root=tmp_path)) + + +def test_qml010_ac02_default_undirected_export_reload_keeps_qml_direction(tmp_path): + """Default graph consumers retain each QML relationship's actual source and target.""" + project(tmp_path) + result = ex.extract(ex.collect_files(tmp_path, root=tmp_path), root=tmp_path, + cache_root=tmp_path / "cache", parallel=False) + graph = build_from_json(result, root=tmp_path) + assert not graph.is_directed() + target = tmp_path / "default-graph.json" + assert to_json(graph, {}, str(target)) + serialized = json.loads(target.read_text(encoding="utf-8")) + # The JSON source/target direction and the reloaded consumer direction are + # independent boundaries: an undirected adjacency iteration is insufficient. + expected = {(edge["source"], edge["target"], edge["relation"], edge.get("context")) for edge in result["edges"]} + assert {(edge["source"], edge["target"], edge["relation"], edge.get("context")) for edge in serialized["links"]} == expected + restored = load_node_link_graph(serialized) + assert not restored.is_directed() + assert portable_graph(restored) == portable_graph(graph) + + +@pytest.mark.parametrize("extension", ["js", "mjs"]) +@pytest.mark.parametrize("same_stem", [False, True]) +def test_qml010_ac02_literal_script_import_keeps_generic_file_endpoint_after_build_reload(tmp_path, extension, same_stem): + """A resolved QML import must survive the cross-language graph projection guard.""" + filename = f"helpers.{extension}" + source = "function help() { return 1; }\n" if extension == "js" else "export function help() { return 1; }\n" + helper = write_qml(tmp_path, filename, source) + caller = write_qml(tmp_path, source=f'import "{filename}" as Helpers\nQtObject {{ property int value: Helpers.help() }}\n') + paths = [caller, helper] + if same_stem: + other_extension = "mjs" if extension == "js" else "js" + paths.append(write_qml(tmp_path, f"helpers.{other_extension}", "function help() { return 2; }\n")) + result = ex.extract(paths, root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + targets = [node for node in result["nodes"] if node["source_file"] == filename and node["label"] == filename] + sites = [node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "import_resolution"] + assert len(targets) == len(sites) == 1 + assert qml_metadata(sites[0])["status"] == "resolved" + expected = [edge for edge in result["edges"] if edge["source"] == sites[0]["id"] and edge["target"] == targets[0]["id"]] + assert len(expected) == 1 and expected[0]["relation"] == "imports" + graph = build_from_json(result, directed=True, root=tmp_path) + assert graph.has_edge(sites[0]["id"], targets[0]["id"]) + target = tmp_path / "roundtrip.json" + assert to_json(graph, {}, str(target)) + restored = load_node_link_graph(json.loads(target.read_text(encoding="utf-8"))) + # Directed JSON deliberately carries direction in its source/target fields; + # its writer need not retain the auxiliary undirected loader markers. + assert restored.is_directed() + edge = restored.edges[sites[0]["id"], targets[0]["id"]] + assert (edge["relation"], edge["confidence"]) == ("imports", "INFERRED") + assert edge.get("_src", sites[0]["id"]) == sites[0]["id"] + assert edge.get("_tgt", targets[0]["id"]) == targets[0]["id"] diff --git a/tests/test_qml_metadata.py b/tests/test_qml_metadata.py new file mode 100644 index 0000000000..e71ee1a097 --- /dev/null +++ b/tests/test_qml_metadata.py @@ -0,0 +1,115 @@ +"""QML-004/009 metadata boundary: literal records, safe failures and provenance.""" + +import json + +import pytest + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qml_metadata import MAX_METADATA_BYTES, extract_qmldir, parse_qmldir +from graphify.security import sanitize_metadata + + +def test_qmldir_literal_records_and_utf8_offsets(): + """Supported metadata retains independent records beyond sanitizer's list cap.""" + source = "# α\nmodule Public.Tools\n" + "".join(f"Widget{i} 1.0 Widget{i}.qml\n" for i in range(60)) + source += "singleton State 1.1 State.qml\ninternal Hidden Hidden.qml\n" + source += "import Public.Base auto\ndepends Public.Runtime 1.0\noptional plugin tools\n" + source += "typeinfo tools.qmltypes\nclassname ToolsPlugin\ndesignersupported\n" + result = parse_qmldir(source, relative_file="Public/Tools/qmldir") + assert not result.get("error") + assert len([node for node in result["nodes"] if qml_metadata(node).get("kind") == "export"]) == 62 + module = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "module") + md = qml_metadata(module) + assert source.encode()[md["span"]["start_byte"]:md["span"]["end_byte"]] == b"module Public.Tools" + assert md["span"]["start_row"] == 1 + assert all(edge["confidence"] == "EXTRACTED" for edge in result["edges"]) + payload = json.loads(json.dumps(result)) + for node in payload["nodes"]: + node["metadata"] = sanitize_metadata(node["metadata"]) + assert [qml_metadata(node).get("raw_name") for node in payload["nodes"]] == [ + qml_metadata(node).get("raw_name") for node in result["nodes"]] + + +@pytest.mark.parametrize("bom", [b"", b"\xef\xbb\xbf"]) +@pytest.mark.parametrize("newline", [b"\n", b"\r\n"]) +@pytest.mark.parametrize("leading_comment", [False, True]) +def test_qmldir_reader_preserves_original_byte_spans(tmp_path, bom, newline, leading_comment): + """Production reads preserve UTF-8 BOM, Unicode and CRLF in every span.""" + lines = ["module Public.Tools", "Widget 1.0 Écran.qml", "# β", "typeinfo types.qmltypes"] + if leading_comment: + lines.insert(0, "# α") + raw = bom + newline.join(line.encode("utf-8") for line in lines) + newline + path = tmp_path / "qmldir" + path.write_bytes(raw) + result = extract_qmldir(path, root=tmp_path) + direct = parse_qmldir(raw.decode("utf-8")) + assert not result.get("error") + assert result == direct + spans = [qml_metadata(item)["span"] for item in result["nodes"] + result["edges"]] + file_span = qml_metadata(result["nodes"][0])["span"] + assert (file_span["start_byte"], file_span["end_byte"]) == (0, len(raw)) + for span in spans: + for end in ("start", "end"): + prefix = raw[:span[f"{end}_byte"]] + assert span[f"{end}_row"] == prefix.count(b"\n") + assert span[f"{end}_column"] == len(prefix) - prefix.rfind(b"\n") - 1 + expected = [line.encode("utf-8") for line in lines if not line.startswith("#")] + assert [raw[span["start_byte"]:span["end_byte"]] for span in spans[1:len(result["nodes"])]] == expected + plain = parse_qmldir(newline.join(line.encode("utf-8") for line in lines).decode("utf-8")) + assert [node["id"] for node in result["nodes"]] == [node["id"] for node in plain["nodes"]] + + +@pytest.mark.parametrize("source,reason", [ + ("Widget 1.0 Widget.qml\nmodule Bad.Order", "invalid_module_declaration"), + ("module Public.Tools\nmodule Public.Tools", "invalid_module_declaration"), + ("module Public.Tools\nWidget 1.0 A.qml\nWidget 1.0 B.qml", "duplicate_export"), + ("module Public.Tools\ninternal Hidden 1.0 Hidden.qml", "unsupported_or_malformed_directive"), + ("module Public.Tools\nimport Public.Base mystery", "invalid_module_version"), + ("module Public.Tools\nplugin " + "a" * 400, "metadata_value_limit"), + ("module Public.Tools\nunknown executable --args", "unsupported_or_malformed_directive"), + ("module Public.Tools\nimport", "invalid_module_dependency"), + ("module Public.Tools\noptional broken", "unsupported_or_malformed_directive"), +]) +def test_qmldir_partial_failure_is_explicit(source, reason): + """Malformed/unsupported input must not be reported as complete coverage.""" + result = parse_qmldir(source) + assert result["partial"] is True + assert result["error"].startswith("QML-META-001") + assert reason in {item["reason"] for item in result["parse_errors"]} + assert result["diagnostics"][0]["severity"] == "error" + + +def test_qmldir_read_containment_and_size_limits(tmp_path): + """Declarations never authorize following target files, plugins or host paths.""" + root = tmp_path / "accepted" + root.mkdir() + outside = tmp_path / "qmldir" + outside.write_text("module Outside\n") + assert extract_qmldir(outside, root=root)["error"].startswith("QML-META-002") + path = root / "qmldir" + path.write_bytes(b"x" * (MAX_METADATA_BYTES + 1)) + assert extract_qmldir(path, root=root)["nodes"] == [] + path.write_bytes(b"\xff") + assert extract_qmldir(path, root=root)["error"].startswith("QML-META-002") + path.write_text("module Public.Tools\nWidget 1.0 ../../host.qml\nplugin arbitrary\n") + result = extract_qmldir(path, root=root) + assert not result.get("error") + assert all(node["source_file"] == "qmldir" for node in result["nodes"]) + + +def test_qmldir_ids_are_portable_and_exact_case_sensitive(): + """Case-colliding names and harmless line shifts preserve declaration identity.""" + source = "module Public.Tools\nWidget 1.0 Widget.qml\nwidget 1.0 widget.qml" + before = parse_qmldir(source) + after = parse_qmldir("# editor comment\n" + source) + assert [node["id"] for node in before["nodes"]] == [node["id"] for node in after["nodes"]] + assert len({node["id"] for node in before["nodes"]}) == len(before["nodes"]) + + +def test_qmldir_record_limit_is_reported(monkeypatch): + """Work rejection retains a diagnostic and never claims complete coverage.""" + monkeypatch.setattr("graphify.extractors.qml_metadata.MAX_METADATA_RECORDS", 2) + result = parse_qmldir("module Public.Tools\nA 1.0 A.qml\nB 1.0 B.qml") + assert result["parse_errors"][-1]["reason"] == "metadata_record_limit" + result = parse_qmldir("x" * (MAX_METADATA_BYTES + 1)) + assert result["parse_errors"][-1]["reason"] == "metadata_size_limit" diff --git a/tests/test_qml_parser_probe.py b/tests/test_qml_parser_probe.py new file mode 100644 index 0000000000..650d95aec1 --- /dev/null +++ b/tests/test_qml_parser_probe.py @@ -0,0 +1,234 @@ +"""QML-00 parser characterization; no Graphify language support is registered. + +These independently authored fixtures exercise the pinned parser through the +Language/Parser primitives used by Graphify. They do not execute Qt or corpus JS, +resolve modules, validate qmldir semantics, or verify production requirements. +""" +from __future__ import annotations + +import importlib +from importlib.metadata import version +import json +import os +from pathlib import Path +import subprocess +import sys +from types import ModuleType + +import pytest +from tree_sitter import LANGUAGE_VERSION, MIN_COMPATIBLE_LANGUAGE_VERSION, Language, Parser + +from tests.qml_probe_helpers import OFFLINE_PROBE, field_text, nodes, point_at, text, walk + +FIXTURES = Path(__file__).parent / "fixtures" / "qml" / "parser_probe" +VALID_QML = ("Main.qml", "Modern68.qml", "Grouped.qml", "Script.qml", "UnicodeCRLF.qml", "Imports.qml", "plugins.qmltypes") + + +@pytest.fixture(scope="module") +def pack() -> ModuleType: + """Absence is explicit optional-parser coverage loss, never parser acceptance.""" + pytest.importorskip("tree_sitter_language_pack", reason="QML-00 needs the optional pinned language-pack candidate") + return importlib.import_module("tree_sitter_language_pack") + + +def parse(pack: ModuleType, filename: str): + source = (FIXTURES / filename).read_bytes() + grammar = "qmldir" if filename.startswith("qmldir") else "qmljs" + return source, pack.get_parser(grammar).parse(source).root_node + + +def test_qml00_candidate_distribution_fingerprint(pack): + """Candidate version changes require a new recorded parser decision.""" + assert version("tree-sitter-language-pack") == "0.11.0" + major, minor, patch = map(int, version("tree-sitter").split(".")) + assert (0, 25, 2) <= (major, minor, patch) < (0, 26, 0) + + +@pytest.mark.parametrize("grammar,source,root_type", [ + ("qmljs", b"import QtQuick\nItem {}\n", "program"), + ("qmldir", b"module Example\nMain 1.0 Main.qml\n", "module_definition"), +]) +def test_qml00_binding_uses_graphify_tree_sitter_api(pack, grammar, source, root_type): + """Installed grammar capsules work with current Graphify API/ABI primitives.""" + language = Language(pack.get_binding(grammar)) + assert MIN_COMPATIBLE_LANGUAGE_VERSION <= language.abi_version <= LANGUAGE_VERSION + root = Parser(language).parse(source).root_node + assert root.type == root_type and not root.has_error + assert root.end_byte == len(source) + + +@pytest.mark.parametrize("filename,counts", [ + ("Main.qml", {"ui_import": 4, "ui_object_definition": 2, "ui_property": 5, "ui_signal": 2, "function_declaration": 1}), + ("Modern68.qml", {"ui_pragma": 1, "ui_inline_component": 1, "enum_declaration": 1, "arrow_function": 1}), + ("Grouped.qml", {"ui_object_definition": 3}), + ("Script.qml", {"function_declaration": 1, "arrow_function": 1, "ui_signal": 0, "ui_import": 1}), + ("UnicodeCRLF.qml", {"ui_property": 2, "function_declaration": 1}), + ("Imports.qml", {"ui_import": 4}), + ("plugins.qmltypes", {"ui_object_definition": 6}), +]) +def test_qml00_supported_syntax_corpus(pack, filename, counts): + """Hand-counted declarations and syntax nodes survive representative inputs.""" + _, root = parse(pack, filename) + assert root.type == "program" and not root.has_error + for kind, expected in counts.items(): + assert len(nodes(root, kind)) == expected, (filename, kind) + + +@pytest.mark.parametrize("filename,expected", [ + ("Main.qml", [("QtQuick", None, None), ("QtQuick.Controls", "6.5", "Controls"), ('"./components"', None, "Local"), ('"helpers.js"', None, "Helpers")]), + ("Imports.qml", [("QtQuick", "6", None), ("Example.Widgets", "1.2", None), ("Example.Services", None, "Services"), ('"./components"', None, None)]), +]) +def test_qml00_import_source_version_alias_are_distinct_fields(pack, filename, expected): + """Qualified and quoted sources retain their version/alias without resolution.""" + _, root = parse(pack, filename) + actual = [tuple(field_text(node, name) for name in ("source", "version", "alias")) for node in nodes(root, "ui_import")] + assert actual == expected + + +def test_qml00_properties_signals_methods_and_nested_object_ownership(pack): + """Modifiers, aliases, typed signatures and nested IDs keep AST ownership.""" + _, root = parse(pack, "Main.qml") + properties = {field_text(node, "name"): node for node in nodes(root, "ui_property")} + assert {name: field_text(node, "type") for name, node in properties.items()} == { + "title": "string", "doubled": "int", "extras": "list", "count": "int", "editorText": "alias", + } + for name, modifier in (("title", "required"), ("doubled", "readonly"), ("extras", "default")): + assert [text(node) for node in nodes(properties[name], "ui_property_modifier")] == [modifier] + assert field_text(properties["editorText"], "value") == "editor.text" + parameters = nodes(root, "ui_signal_parameter") + assert [(field_text(node, "name"), field_text(node, "type")) for node in parameters] == [("value", "int"), ("label", "string"), ("value", "int")] + method = nodes(root, "function_declaration")[0] + assert field_text(method, "name") == "bump" and field_text(method, "return_type") == ": int" + owners = {} + for obj in nodes(root, "ui_object_definition"): + initializer = obj.child_by_field_name("initializer") + assert initializer is not None + owners[field_text(obj, "type_name")] = [field_text(node, "value") for node in initializer.named_children if node.type == "ui_binding" and field_text(node, "name") == "id"] + assert owners == {"Item": ["root"], "Controls.TextField": ["editor"]} + + +def test_qml00_inline_component_keeps_local_definition_and_base_type(pack): + """Inline type names and their own alias owner must not be flattened.""" + _, root = parse(pack, "Modern68.qml") + component = nodes(root, "ui_inline_component")[0] + assert field_text(component, "name") == "Tile" + definition = component.child_by_field_name("component") + assert definition is not None + assert field_text(definition, "type_name") == "Rectangle" + assert [field_text(node, "name") for node in nodes(definition, "ui_property")] == ["value", "ownValue"] + assert field_text(nodes(definition, "ui_property")[1], "value") == "tile.value" + binding_names = [field_text(node, "name") for node in nodes(root, "ui_binding")] + assert all(name is not None for name in binding_names) + assert [name for name in binding_names if name is not None and "." in name] == ["Component.onCompleted"] + + +def test_qml00_grouped_property_object_definition_pitfall_is_explicit(pack): + """Pinned grammar treats grouped properties as objects; semantic handling is pending.""" + _, root = parse(pack, "Grouped.qml") + definitions = nodes(root, "ui_object_definition") + assert [field_text(node, "type_name") for node in definitions] == ["Text", "anchors", "font"] + assert field_text(definitions[1], "initializer") == "{ left: parent.left; topMargin: 4 }" + + +def test_qml00_embedded_js_keeps_scopes_and_ignores_literal_declarations(pack): + """Shadowed variables stay in separate blocks; strings/comments invent no signals.""" + _, root = parse(pack, "Script.qml") + variables = [node for node in nodes(root, "variable_declarator") if field_text(node, "name") == "value"] + assert len(variables) == 2 + blocks = [] + for variable in variables: + assert variable.parent is not None + block = variable.parent.parent + assert block is not None and block.type == "statement_block" + blocks.append(block) + assert blocks[0] != blocks[1] + assert [field_text(node, "function") for node in nodes(root, "call_expression")] == ["transform", "evaluate"] + assert not nodes(root, "ui_signal") + assert any("signal fake" in text(node) for node in nodes(root, "string")) + + +@pytest.mark.parametrize("filename", VALID_QML + ("Incomplete.qml", "Malformed.qml", "Empty.qml", "qmldir", "qmldir.modern")) +def test_qml00_source_ranges_use_original_utf8_bytes(pack, filename): + """All node ranges/points remain bounded in the original, unnormalized source.""" + source, root = parse(pack, filename) + assert root.end_byte == len(source) + for node in walk(root): + assert 0 <= node.start_byte <= node.end_byte <= len(source) + assert tuple(node.start_point) == point_at(source, node.start_byte) + assert tuple(node.end_point) == point_at(source, node.end_byte) + source[node.start_byte:node.end_byte].decode("utf-8") + + +def test_qml00_unicode_crlf_columns_are_byte_offsets(pack): + """A non-ASCII prefix changes byte columns without changing CRLF row counts.""" + source, root = parse(pack, "UnicodeCRLF.qml") + assert source.count(b"\r\n") == source.count(b"\n") > 0 + properties = nodes(root, "ui_property") + assert [field_text(node, "name") for node in properties] == ["café", "après"] + second_name = properties[1].child_by_field_name("name") + assert second_name is not None + assert tuple(second_name.start_point)[0] == 4 + prefix = source.split(b"\r\n")[4][:second_name.start_point.column] + assert second_name.start_point.column == len(prefix) > len(prefix.decode("utf-8")) + assert text(nodes(root, "function_declaration")[0].child_by_field_name("name")) == "résumé" + + +@pytest.mark.parametrize("filename", ["Incomplete.qml", "Malformed.qml", "Empty.qml"]) +def test_qml00_malformed_empty_partial_inputs_do_not_look_successful(pack, filename): + """Error status survives recovery, including an ERROR root in an editor buffer.""" + source, root = parse(pack, filename) + assert root.has_error + assert any(node.is_error or node.is_missing for node in walk(root)) + if filename != "Empty.qml": + retained = [node for node in nodes(root, "ui_property") if field_text(node, "name") == "retained"] + assert len(retained) == 1 + assert source[retained[0].start_byte:retained[0].end_byte].startswith(b"property ") + else: + assert source == b"" and root.end_byte == 0 + + +@pytest.mark.parametrize("filename,expected", [ + ("qmldir", ["module Example.Widgets", "Main 1.0 Main.qml", "singleton Theme 1.0 Theme.qml", "internal Helper Helper.qml", "typeinfo plugins.qmltypes", "plugin exampleplugin", "classname ExamplePlugin", "depends QtQuick.Controls 6.5"]), + ("qmldir.modern", ["module Example.Widgets", "optional plugin exampleplugin", "import QtQuick auto", "prefer :/qt/qml/Example/Widgets/", "Main Main.qml"]), +]) +def test_qml00_qmldir_preserves_basic_and_modern_command_tokens(pack, filename, expected): + """Module-descriptor tokens remain available for a future directive validator.""" + _, root = parse(pack, filename) + assert root.type == "module_definition" and not root.has_error + # qmldir command spans include the line terminator; compare its raw contents + # independently of checkout line endings, while range tests retain all bytes. + assert [text(node).rstrip("\r\n") for node in nodes(root, "command")] == expected + + +@pytest.mark.parametrize("source", [b"module\n", b"Main wrong Main.qml\n", b"nonsense albatross unknown\n"]) +def test_qml00_qmldir_missing_semantic_validation_is_explicit(pack, source): + """Characterize rejected-by-contract metadata accepted by the generic grammar.""" + root = pack.get_parser("qmldir").parse(source).root_node + assert not root.has_error + assert len(nodes(root, "command")) == 1 + + +def test_qml00_qmldir_invalid_tokens_report_error(pack): + """Lexically invalid metadata is distinct from permissive directive syntax.""" + root = pack.get_parser("qmldir").parse(b"@@@\n").root_node + assert root.has_error and nodes(root, "ERROR") + + +def test_qml00_unavailable_grammar_has_explicit_failure(pack): + """Missing native grammar must raise a bounded library error, not empty success.""" + with pytest.raises(LookupError, match="Could not find language library"): + pack.get_parser("qml_probe_missing_grammar") + + +def test_qml00_fresh_isolated_subprocess_needs_no_network_or_corpus_execution(pack, tmp_path): + """Cold installed invocation works with no repository imports or download cache.""" + inherited = {key: value for key, value in os.environ.items() if key.upper() in {"PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP"}} + for key in ("HOME", "USERPROFILE", "XDG_CACHE_HOME", "APPDATA", "LOCALAPPDATA"): + inherited[key] = str(tmp_path) + child = subprocess.run([sys.executable, "-I", "-c", OFFLINE_PROBE, str(FIXTURES.resolve())], cwd=tmp_path, env=inherited, capture_output=True, text=True, encoding="utf-8", timeout=30) + assert child.returncode == 0, child.stderr + result = json.loads(child.stdout) + assert result["network_denied"] is True and result["unavailable"] == "LookupError" + assert set(result["results"]) == {"qmljs", "qmldir"} + for grammar in result["results"].values(): + assert grammar["has_error"] is False and grammar["end_byte"] == grammar["size"] > 0 diff --git a/tests/test_qml_platform_matrix.py b/tests/test_qml_platform_matrix.py new file mode 100644 index 0000000000..d199ac2feb --- /dev/null +++ b/tests/test_qml_platform_matrix.py @@ -0,0 +1,71 @@ +"""Keep the declared artifact lanes and clean-install source boundaries honest.""" +from __future__ import annotations + +import ast +from itertools import product +from pathlib import Path +import re +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parent.parent + + +def test_advertised_twelve_lanes_equal_actual_wheel_workflow(): + workflow = (ROOT / ".github/workflows/qml-wheel.yml").read_text(encoding="utf-8") + os_values = re.search(r"^\s+os: \[(.*?)\]$", workflow, re.MULTILINE) + python_values = re.search(r"^\s+python: \[(.*?)\]$", workflow, re.MULTILINE) + assert os_values is not None and python_values is not None + operating_systems = [value.strip() for value in os_values.group(1).split(",")] + interpreters = ast.literal_eval("[" + python_values.group(1) + "]") + actual = set(product(operating_systems, interpreters)) + document = (ROOT / "docs/qt-qml/PLATFORM_MATRIX.md").read_text(encoding="utf-8") + declared = set(re.findall(r"^\| ((?:ubuntu|windows|macos)-latest) \| (3\.\d+) \|", document, re.MULTILINE)) + assert len(actual) == len(declared) == 12 + assert declared == actual + + +def test_wheel_runner_keeps_optional_core_and_isolated_offline_smoke_boundaries(tmp_path, monkeypatch): + # Characterize the shipped CI runner rather than replace it with a mock CLI. + runner = (ROOT / "tests/qml_ci.py").read_text(encoding="utf-8") + smoke = (ROOT / "tests/qml_installed_smoke.py").read_text(encoding="utf-8") + tree = ast.parse(runner) + assert not any(isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr in {"skip", "xfail"} for node in ast.walk(tree)) + assert 'str(wheel) + "[qml,watch]"' in runner + assert 'interpreter(core), str(wheel)' in runner + assert 'interpreter(extra), "-I", smoke' in runner + assert 'interpreter(core), "-I", smoke, "--core-only"' in runner + assert '"GRAPHIFY_QML_TEST_WHEEL"' in runner or "GRAPHIFY_QML_TEST_WHEEL=" in runner + # Execute only the trusted runner's selection expression against real files. + # This preserves native joint-case admission and Linux artifact isolation + # without invoking builds, installs or the runner's subprocess boundary. + selection = next(node.value for node in ast.walk(tree) + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "tests" + for target in node.targets)) + expression = compile(ast.Expression(selection), "qml_ci.py selection", "eval") + corpus = tmp_path / "tests" + corpus.mkdir() + selected = ["test_qml_wheel_artifact.py", "test_qml_contract.py", + "test_qt_contract.py", "test_upstream_qt_contract.py"] + for name in [*reversed(selected), "test_python_contract.py", "qml_ci.py", + "test_upstream_other.py", "test_qt_contract.txt"]: + (corpus / name).write_text("", encoding="utf-8") + # A real filesystem may already enumerate alphabetically. Reverse only its + # actual fixture entries so the production sort must establish stable order. + original_glob = Path.glob + def unsorted_glob(self, pattern): + entries = original_glob(self, pattern) + if self == corpus and pattern == "test_*.py": + return iter(sorted(entries, key=lambda path: path.name, reverse=True)) + return entries + monkeypatch.setattr(Path, "glob", unsorted_glob) + for arguments, expected in [([], sorted(f"tests/{name}" for name in selected)), + (["--artifact-only"], ["tests/test_qml_wheel_artifact.py"])]: + actual = eval(expression, {"root": tmp_path, "sys": SimpleNamespace(argv=arguments)}) + assert [Path(path).as_posix() for path in actual] == expected + assert '"socket.connect"' in smoke and '"subprocess.Popen"' in smoke + assert "sys.addaudithook(offline_guard)" in smoke + assert "QML_PARSER_MISSING" in smoke + assert "validate_extraction(batch)" in smoke and "build_from_json(batch" in smoke diff --git a/tests/test_qml_read_failure.py b/tests/test_qml_read_failure.py new file mode 100644 index 0000000000..5d882ff1bd --- /dev/null +++ b/tests/test_qml_read_failure.py @@ -0,0 +1,49 @@ +"""A real source-disappearance read failure cannot commit a partial QML graph.""" +import pytest + +import graphify.__main__ as mainmod +import graphify.extract as ex +from graphify.watch import _rebuild_code +from tests.qml_persistence_helpers import healthy_qml, make_project, protected_snapshot + + +@pytest.mark.parametrize("operation", ["extract", "update"]) +def test_qml_read_race_preserves_prior_products_then_recovers(tmp_path, monkeypatch, operation): + """QML-001/002: OS read failure passes through real extraction and persistence gates.""" + project = make_project(tmp_path) + source = project / "Main.qml" + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _rebuild_code(project, no_cluster=True, acquire_lock=False) + out = project / "graphify-out" + # A prior full build may also own presentation products. The integrity gate + # must return before any of those products or the root marker can advance. + for name in ("graph.html", "GRAPH_REPORT.md", ".graphify_labels.json", "callflow.html"): + (out / name).write_text("previous complete output\n", encoding="utf-8") + before = protected_snapshot(out) + source.write_text("Item { property int changed: 4 }\n", encoding="utf-8") + + def source_disappears(path): + # Simulate a filesystem race after discovery, with an actual failed read. + path.unlink() + path.read_bytes() + + monkeypatch.setitem(ex._DISPATCH, ".qml", source_disappears) + if operation == "extract": + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", [ + "graphify", "extract", str(project), "--code-only", "--no-cluster", + "--force", "--allow-partial", + ]) + with pytest.raises(SystemExit) as info: + mainmod.main() + assert info.value.code == 1 + else: + assert not _rebuild_code(project, changed_paths=[source], no_cluster=True, + force=True, acquire_lock=False) + assert not source.exists() + assert protected_snapshot(out) == before + + # Restoring the source and parser recovers without deleting graph/cache data. + source.write_text("Item { property int changed: 5 }\n", encoding="utf-8") + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False) diff --git a/tests/test_qml_resolution.py b/tests/test_qml_resolution.py new file mode 100644 index 0000000000..c4c3182379 --- /dev/null +++ b/tests/test_qml_resolution.py @@ -0,0 +1,190 @@ +"""QML-004 production lookup/projection acceptance and corpus-boundary negatives.""" + +import copy +import json +from pathlib import Path + +import pytest + +from graphify.extractors.qml_facts import qml_metadata +from graphify.qml_resolution import build_qml_index, resolve_qml_project +from tests.qml_resolution_helpers import component_key, index_for, module, named + + +def test_aliased_directory_and_uri_imports_do_not_cross_bind(tmp_path): + """AC01/02: same-label exports remain distinct under document-local aliases.""" + sources = {**module("Public.One"), **module("Public.Two"), + "local/Button.qml": "QtObject {}", + "Main.qml": 'import Public.One 1.0 as One\nimport Public.Two 1.0 as Two\nimport "local" as Local\nOne.Button {}', + "Other.qml": "QtObject {}"} + index, _, nodes, _ = index_for(tmp_path, sources) + scope = component_key(nodes) + for qualified, target in [("One.Button", "Public/One/Button.qml"), + ("Two.Button", "Public/Two/Button.qml"), ("Local.Button", "local/Button.qml")]: + result = index.resolve_type("Main.qml", scope, qualified) + assert result.target_id == named(nodes, target, "component")["id"] + assert result.evidence + assert index.resolve_type("Main.qml", scope, "Button").status == "unavailable" + assert index.resolve_type("Other.qml", component_key(nodes, "Other.qml"), "One.Button").status == "unavailable" + + +def test_version_availability_and_latest_compatible_export(tmp_path): + """AC01/02/03: available module version precedes per-type introduction lookup.""" + sources = {"Public/Tools/qmldir": "module Public.Tools\nButton 1.0 Old.qml\nButton 1.1 New.qml\nWindow 1.2 Window.qml", + "Public/Tools/Old.qml": "QtObject {}", "Public/Tools/New.qml": "QtObject {}", + "Public/Tools/Window.qml": "QtObject {}", "Main.qml": "QtObject {}"} + index, _, nodes, _ = index_for(tmp_path, sources) + for major, minor, target in [(1, 0, "Old.qml"), (1, 2, "New.qml"), (1, None, "New.qml"), (None, None, "New.qml")]: + result = index.module_type("Public.Tools", major, minor, "Button", importer="Main.qml") + assert result.target_id == named(nodes, "Public/Tools/" + target, "component")["id"] + for major, minor in [(1, 3), (2, 0)]: + result = index.module_type("Public.Tools", major, minor, "Button", importer="Main.qml") + assert result.status == "unavailable" + assert result.reason == "module_version_unavailable" + + +def test_competing_providers_and_missing_modules_have_no_target_edges(tmp_path): + """AC03: source-owned unresolved sites survive, arbitrary targets never do.""" + sources = {**module("Public.One"), **module("Public.Two"), + "Main.qml": "import Public.One 1.0\nimport Public.Two 1.0\nimport Missing.Api 1.0\nButton {}"} + _, per_file, nodes, edges = index_for(tmp_path, sources) + resolve_qml_project(per_file, nodes, edges, root=tmp_path) + site = named(nodes, "Main.qml", "type_use") + assert qml_metadata(site)["status"] == "ambiguous" + assert len(qml_metadata(site)["candidates"]) == 2 + assert not any(edge["source"] == site["id"] and edge["relation"] == "uses" for edge in edges) + missing = [node for node in nodes if qml_metadata(node).get("kind") == "import_resolution" and + node["label"] == "Missing.Api"][0] + assert qml_metadata(missing)["reason"] == "module_not_in_import_roots" + assert not any(edge["source"] == missing["id"] and edge["relation"] == "imports" for edge in edges) + + +def test_declared_roots_and_remote_import_never_expand_corpus(tmp_path, monkeypatch): + """AC04: accepted facts are the complete source set, independent of disk files.""" + sources = {"src/Public/Tools/qmldir": "module Public.Tools\nButton 1.0 Button.qml", + "src/Public/Tools/Button.qml": "QtObject {}", + "unrelated/Button.qml": "QtObject {}", + "Main.qml": 'import Public.Tools 1.0\nimport "https://invalid.example/source" as Remote\nButton {}'} + index, per_file, nodes, edges = index_for(tmp_path, sources) + scope = component_key(nodes) + assert index.resolve_type("Main.qml", scope, "Button").status == "unavailable" + index = build_qml_index(nodes, edges, root=tmp_path, import_roots=["src", "../outside"]) + assert index.resolve_type("Main.qml", scope, "Button").target_id == named(nodes, "src/Public/Tools/Button.qml", "component")["id"] + def forbidden(*args, **kwargs): + raise AssertionError("Resolver attempted source expansion") + monkeypatch.setattr(Path, "read_text", forbidden) + monkeypatch.setattr(Path, "read_bytes", forbidden) + import socket + monkeypatch.setattr(socket, "create_connection", forbidden) + resolve_qml_project(per_file, nodes, edges, root=tmp_path, import_roots=["src"]) + remote = [node for node in nodes if qml_metadata(node).get("kind") == "import_resolution" and + node["label"].startswith("https:")][0] + assert qml_metadata(remote)["reason"] == "import_outside_corpus" + + +def test_projection_is_repeatable_owned_and_json_portable(tmp_path): + """Derived sites preserve roles without changing borrowed declarations/context.""" + _, per_file, nodes, edges = index_for(tmp_path, {**module("Public.One"), + "Main.qml": "import Public.One 1.0\nButton {}"}) + before = copy.deepcopy(nodes) + fresh = {path: value for path, value in per_file.items() if path.name == "Main.qml"} + resolve_qml_project(fresh, nodes, edges, root=tmp_path) + assert nodes[:len(before)] == before + count = (len(nodes), len(edges)) + resolve_qml_project(fresh, nodes, edges, root=tmp_path) + assert (len(nodes), len(edges)) == count + payload = json.loads(json.dumps({"nodes": nodes, "edges": edges})) + index = build_qml_index(payload["nodes"], payload["edges"], root=tmp_path / "relocated") + assert index.resolve_type("Main.qml", component_key(nodes), "Button").status == "resolved" + endpoints = {node["id"] for node in nodes} + assert all(edge["source"] in endpoints and edge["target"] in endpoints for edge in edges) + assert all(qml_metadata(node).get("kind") not in {"type_use", "import_resolution"} or + node["source_file"] == "Main.qml" for node in nodes) + + +def test_module_import_auto_visibility_and_dependency_non_visibility(tmp_path): + """Imported module exports enter the namespace; packaging depends does not.""" + sources = {**module("Public.Base"), "Public/Facade/qmldir": "module Public.Facade\nMarker 1.0 Marker.qml\nimport Public.Base auto", + "Public/Facade/Marker.qml": "QtObject {}", "Public/OnlyDepends/qmldir": + "module Public.OnlyDepends\nMarker 1.0 Marker.qml\ndepends Public.Base 1.0", + "Public/OnlyDepends/Marker.qml": "QtObject {}", "Main.qml": "QtObject {}"} + index, _, nodes, _ = index_for(tmp_path, sources) + expected = named(nodes, "Public/Base/Button.qml", "component")["id"] + assert index.module_type("Public.Facade", 1, 0, "Button", importer="Main.qml").target_id == expected + assert index.module_type("Public.OnlyDepends", 1, 0, "Button", importer="Main.qml").status == "unavailable" + + +def test_versioned_layout_and_missing_version_evidence(tmp_path): + """Declared versioned roots and exact module-version evidence govern selection.""" + sources = {"Public/Tools.1/qmldir": "module Public.Tools\nButton 1.0 Major.qml", + "Public/Tools.1/Major.qml": "QtObject {}", "Public/Tools.1.0/qmldir": + "module Public.Tools\nButton 1.0 Exact.qml", "Public/Tools.1.0/Exact.qml": "QtObject {}", + "Public/Unversioned/qmldir": "module Public.Unversioned\nButton Button.qml", + "Public/Unversioned/Button.qml": "QtObject {}", "Main.qml": "QtObject {}"} + index, _, nodes, _ = index_for(tmp_path, sources) + assert index.module_type("Public.Tools", 1, 0, "Button", importer="Main.qml").target_id == named(nodes, "Public/Tools.1.0/Exact.qml", "component")["id"] + assert index.module_type("Public.Unversioned", 1, 0, "Button", importer="Main.qml").reason == "module_version_unavailable" + assert index.module_type("Public.Unversioned", None, None, "Button", importer="Main.qml").status == "resolved" + + +@pytest.mark.parametrize("directed", [False, True]) +def test_projection_graph_build_export_reload_preserves_all_site_roles(tmp_path, directed): + """Independent import/type sites survive the actual simple graph and JSON export.""" + from graphify.build import build_from_json + from graphify.export import to_json + from graphify.paths import load_node_link_graph + _, per_file, nodes, edges = index_for(tmp_path, {**module("Public.One"), + "Main.qml": "import Public.One 1.0\nButton { Button {} }"}) + resolve_qml_project(per_file, nodes, edges, root=tmp_path) + graph = build_from_json({"nodes": nodes, "edges": edges}, directed=directed, root=tmp_path) + def mechanism(source, target, relation): + endpoints = (source, target) if directed else tuple(sorted((source, target))) + return endpoints, relation + expected = {mechanism(edge["source"], edge["target"], edge["relation"]) for edge in edges} + assert {mechanism(source, target, data["relation"]) for source, target, data in graph.edges(data=True)} == expected + target = tmp_path / "roundtrip.json" + assert to_json(graph, {}, str(target)) + restored = load_node_link_graph(json.loads(target.read_text())) + assert {mechanism(source, target, data["relation"]) for source, target, data in restored.edges(data=True)} == expected + assert len([nid for nid in restored if qml_metadata(restored.nodes[nid]).get("kind") == "type_use" and + restored.nodes[nid]["source_file"] == "Main.qml"]) == 2 + + +def test_directory_and_script_projection_and_ignored_disk_provider(tmp_path): + """Import roles select accepted namespaces/files; ignored disk data stays invisible.""" + sources = {"local/Widget.qml": "QtObject {}", "helpers.js": "function answer() { return 42; }", + "Main.qml": 'import "local" as Local\nimport "helpers.js" as Helpers\nimport Ignored.Api 1.0\nLocal.Widget {}'} + _, per_file, nodes, edges = index_for(tmp_path, sources) + excluded = tmp_path / "Ignored/Api" + excluded.mkdir(parents=True) + (excluded / "qmldir").write_text("module Ignored.Api\nWidget 1.0 Widget.qml") + (excluded / "Widget.qml").write_text("QtObject {}") + resolve_qml_project(per_file, nodes, edges, root=tmp_path) + for literal, target_kind in [("local", "directory_namespace"), ("helpers.js", None)]: + site = [node for node in nodes if qml_metadata(node).get("kind") == "import_resolution" and node["label"] == literal][0] + resolved = [edge["target"] for edge in edges if edge["source"] == site["id"] and edge["relation"] == "imports"] + assert len(resolved) == 1 + target = next(node for node in nodes if node["id"] == resolved[0]) + assert qml_metadata(target).get("kind") == target_kind + ignored = [node for node in nodes if qml_metadata(node).get("kind") == "import_resolution" and node["label"] == "Ignored.Api"][0] + assert qml_metadata(ignored)["status"] == "unavailable" + + +def test_recursive_module_queries_are_bounded_and_resource_prefer_is_explicit(tmp_path, monkeypatch): + """Metadata cycles, branch limits and resource redirection never guess providers.""" + sources = {**module("Public.Base"), "Public/Facade/qmldir": "module Public.Facade\nMarker 1.0 Marker.qml\nimport Public.Base auto", + "Public/Facade/Marker.qml": "QtObject {}", "Public/Resources/qmldir": + "module Public.Resources\nWidget 1.0 Widget.qml\nprefer :/resources/", "Public/Resources/Widget.qml": "QtObject {}"} + index, _, _, _ = index_for(tmp_path, sources) + assert index.module_type("Public.Resources", 1, 0, "Widget", importer="Main.qml").reason == "preferred_path_requires_resource_index" + monkeypatch.setattr("graphify.qml_module_index.MAX_MODULE_RESOLUTION_STEPS", 1) + assert index.module_type("Public.Facade", 1, 0, "Button", importer="Main.qml").reason == "module_resolution_work_limit" + + +def test_module_script_exports_have_separate_lookup_roles(tmp_path): + """A script namespace cannot satisfy an object type use with the same spelling.""" + sources = {"Public/Tools/qmldir": "module Public.Tools\nHelpers 1.0 helpers.js", + "Public/Tools/helpers.js": "function answer() { return 42; }", "Main.qml": "QtObject {}"} + index, _, _, _ = index_for(tmp_path, sources) + assert index.module_type("Public.Tools", 1, 0, "Helpers", importer="Main.qml").status == "unavailable" + assert index.module_script("Public.Tools", 1, 0, "Helpers", importer="Main.qml").status == "resolved" diff --git a/tests/test_qml_resolver_safety.py b/tests/test_qml_resolver_safety.py new file mode 100644 index 0000000000..0abda61da7 --- /dev/null +++ b/tests/test_qml_resolver_safety.py @@ -0,0 +1,42 @@ +"""Production join failures must not publish partial Qt/QML graphs.""" +from pathlib import Path + +import pytest + +from graphify.extract import extract +from graphify.qml_safety import QmlSafetyError, require_complete_qml + + +def test_native_join_failure_is_guarded_and_successful_retry_is_clean(tmp_path, monkeypatch): + import graphify.qml_resolution as resolver + path = tmp_path / "Main.qml" + path.write_text("Item { property int value: 3 }", encoding="utf-8") + real = resolver.resolve_qml_project + + def broken(*args, **kwargs): + args[1].append({"id": "partial_join_must_not_escape"}) + raise RuntimeError("private backend failure details") + + monkeypatch.setattr(resolver, "resolve_qml_project", broken) + failed = extract([path], root=tmp_path, cache_root=tmp_path, parallel=False) + assert failed["qml_failures"][0]["code"] == "QML_RESOLUTION_FAILED" + assert not any(n["id"] == "partial_join_must_not_escape" for n in failed["nodes"]) + assert "private backend" not in str(failed) + with pytest.raises(QmlSafetyError, match="QML_GRAPH_PRESERVED"): + require_complete_qml(failed, [path], root=tmp_path, operation="test") + monkeypatch.setattr(resolver, "resolve_qml_project", real) + retried = extract([path], root=tmp_path, cache_root=tmp_path, parallel=False) + assert not retried["qml_failures"] and not retried["failed_sources"] + require_complete_qml(retried, [path], root=tmp_path, operation="retry") + + +def test_nested_object_scope_keys_have_fixed_transport_size(tmp_path): + from graphify.extractors.qml import extract_qml + path = tmp_path / "Nested.qml" + path.write_text("Item {" * 30 + "property int value: 1" + "}" * 30, encoding="utf-8") + result = extract_qml(path, root=tmp_path) + assert not result.get("error") + scopes = [n["metadata"]["qml"]["object_scope_key"] for n in result["nodes"] + if n["metadata"]["qml"]["kind"] == "object"] + assert len(scopes) == 30 and len(set(scopes)) == 30 + assert {len(scope) for scope in scopes} == {64} diff --git a/tests/test_qml_safety.py b/tests/test_qml_safety.py new file mode 100644 index 0000000000..6e6231397b --- /dev/null +++ b/tests/test_qml_safety.py @@ -0,0 +1,73 @@ +"""QML-001/002/011 integrity gates inspect source ownership, not graph counts.""" +import pytest + +from graphify.qml_safety import ( + QmlSafetyError, is_qml_path, qml_refresh_required, require_complete_qml, + require_qml_watch_root, +) + + +@pytest.mark.parametrize("name", ["Main.qml", "MAIN.QML", "types.qmltypes", "qmldir"]) +def test_qml_source_metadata_recognition(name): + assert is_qml_path(name) + + +@pytest.mark.parametrize("name", ["qmldir.txt", "QMLDIR", "app.py", "qml"]) +def test_qml_named_metadata_does_not_admit_unrelated_files(name): + assert not is_qml_path(name) + + +@pytest.mark.parametrize("marker", ["error", "partial", "parse_errors", "failed_sources", "qml_failures"]) +def test_guard_rejects_unsafe_qml_with_surviving_nodes(tmp_path, marker): + """Recovered nodes cannot make a failed component scope authoritative.""" + path = tmp_path / "Main.qml" + result = {"nodes": [{"id": "kept", "source_file": "Main.qml"}], "edges": []} + value = True + if marker == "failed_sources": + value = [str(path)] + elif marker == "qml_failures": + value = [{"code": "QML_PARSE_PARTIAL", "source_file": str(path)}] + result[marker] = value + with pytest.raises(QmlSafetyError, match="QML_GRAPH_PRESERVED"): + require_complete_qml(result, [path], operation="extract", root=tmp_path) + + +def test_omitted_qml_contribution_cannot_be_reported_complete(tmp_path): + with pytest.raises(QmlSafetyError, match="Main.qml"): + require_complete_qml({"nodes": []}, [tmp_path / "Main.qml"], operation="extract", root=tmp_path) + + +def test_clean_qml_and_unrelated_failure_keep_separate_contracts(tmp_path): + """An existing Python error is outside the QML publication policy.""" + result = {"nodes": [{"source_file": "Main.qml"}], "failed_sources": ["bad.py"]} + require_complete_qml(result, [tmp_path / "Main.qml"], operation="extract", root=tmp_path) + require_complete_qml({"nodes": [], "error": "old behavior"}, ["bad.py"], operation="extract") + + +def test_diagnostic_escapes_delimiters_and_does_not_print_foreign_root(tmp_path): + """Failure evidence contains a bounded escaped filename, never raw source.""" + source = tmp_path / 'hidden' / 'bad\n".qml' + with pytest.raises(QmlSafetyError) as info: + require_complete_qml({"nodes": [], "failed_sources": [str(source)]}, [source], operation="extract") + message = str(info.value) + assert str(tmp_path) not in message + assert '\\n' in message and '\\"' in message + assert "\n" not in message + + +@pytest.mark.parametrize("changed", [["Main.qml"], ["qmldir"], ["types.qmltypes"], ["helpers.js"]]) +def test_qml_dependencies_widen_to_conservative_refresh(changed): + assert qml_refresh_required(["Main.qml", "keep.py"], changed) + + +def test_non_qml_and_deleted_final_component_refresh_contracts(): + assert not qml_refresh_required(["keep.py"], ["helpers.js"]) + assert not qml_refresh_required(["Main.qml"], ["keep.py"]) + assert qml_refresh_required(["keep.py"], ["deleted.qml"]) + + +def test_qml_subfolder_watch_rejects_before_rebasing(tmp_path): + with pytest.raises(QmlSafetyError, match="QML_ROOT_MISMATCH"): + require_qml_watch_root(["Main.qml"], project_root=tmp_path, watch_root=tmp_path / "ui") + require_qml_watch_root(["keep.py"], project_root=tmp_path, watch_root=tmp_path / "src") + require_qml_watch_root(["Main.qml"], project_root=tmp_path, watch_root=tmp_path) diff --git a/tests/test_qml_scope.py b/tests/test_qml_scope.py new file mode 100644 index 0000000000..68e2b47bf5 --- /dev/null +++ b/tests/test_qml_scope.py @@ -0,0 +1,129 @@ +"""QML-005 component IDs, shadowing, singletons and source-only member lookup.""" + +import copy + +from graphify.extractors.qml_facts import encode_metadata, qml_metadata +from graphify.qml_resolution import build_qml_index +from tests.qml_resolution_helpers import component_key, index_for, named + + +def _member(index, nodes, file, receiver, expression, *, lexical_names=()): + owner = named(nodes, file, "object", receiver) + md = qml_metadata(owner) + return index.resolve_member(file, md["component_key"], md["object_scope_key"], expression, + lexical_names=lexical_names) + + +def test_component_ids_do_not_leak_between_files_or_inline_components(tmp_path): + """AC01: equal ID spellings are component-local, including nested definitions.""" + sources = {"Main.qml": """QtObject { + id: root + property int value: 1 + component Local: QtObject { id: root; property int value: 2 } + Local { id: child } + }""", "Other.qml": "QtObject { id: root; property int value: 3; QtObject { id: foreign } }"} + index, _, nodes, _ = index_for(tmp_path, sources) + outer = [node for node in nodes if + node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "property" and + qml_metadata(node).get("component_key") == component_key(nodes)][0] + assert _member(index, nodes, "Main.qml", "child", "root.value").target_id == outer["id"] + inner_root = [node for node in nodes if node["source_file"] == "Main.qml" and + qml_metadata(node).get("kind") == "object" and + qml_metadata(node).get("component_key") != component_key(nodes)][0] + md = qml_metadata(inner_root) + inner = index.resolve_member("Main.qml", md["component_key"], md["object_scope_key"], "root.value") + assert inner.target_id != outer["id"] + assert qml_metadata(index.nodes[inner.target_id])["component_key"] == md["component_key"] + assert _member(index, nodes, "Main.qml", "child", "foreign").status == "unavailable" + + +def test_inline_shadow_and_inherited_members_are_distinct_roles(tmp_path): + """AC02/04: local inline names shadow imported types; instances inherit members.""" + sources = {"Public/Tools/qmldir": "module Public.Tools\nBase 1.0 Base.qml", + "Public/Tools/Base.qml": "QtObject { property int inheritedValue: 1 }", + "Main.qml": """import Public.Tools 1.0 + QtObject { id: root; property int Base: 7 + component Base: QtObject { property int inlineValue: 2 } + Base { id: local } + }""", + "Inherited.qml": "import Public.Tools 1.0\nBase { id: derived }"} + index, _, nodes, _ = index_for(tmp_path, sources) + inline = named(nodes, "Main.qml", "inline_component", "Base") + assert index.resolve_type("Main.qml", component_key(nodes), "Base").target_id == inline["id"] + assert _member(index, nodes, "Main.qml", "local", "local.inlineValue").target_id == named(nodes, "Main.qml", "property", "inlineValue")["id"] + inherited = _member(index, nodes, "Inherited.qml", "derived", "inheritedValue") + assert inherited.target_id == named(nodes, "Public/Tools/Base.qml", "property", "inheritedValue")["id"] + same_label = _member(index, nodes, "Main.qml", "root", "Base") + assert same_label.target_id == named(nodes, "Main.qml", "property", "Base")["id"] + assert len({inline["id"], same_label.target_id, named(nodes, "Main.qml", "object", "local")["id"]}) == 3 + + +def test_singleton_pragma_and_qualified_access(tmp_path): + """AC02: singleton metadata must agree with source pragma before member use.""" + sources = {"Public/Tools/qmldir": "module Public.Tools\nsingleton State 1.0 State.qml\nBroken 1.0 Broken.qml", + "Public/Tools/State.qml": "pragma Singleton\nQtObject { property int value: 1 }", + "Public/Tools/Broken.qml": "QtObject { property int value: 2 }", + "Main.qml": "import Public.Tools 1.0 as Tools\nQtObject { id: root }"} + index, _, nodes, _ = index_for(tmp_path, sources) + result = _member(index, nodes, "Main.qml", "root", "Tools.State.value") + assert result.target_id == named(nodes, "Public/Tools/State.qml", "property", "value")["id"] + assert _member(index, nodes, "Main.qml", "root", "Tools.Broken.value").status != "resolved" + bad_nodes = copy.deepcopy(nodes) + # A provider's singleton export without a matching source pragma is rejected. + state = named(nodes, "Public/Tools/State.qml", "component") + for node in bad_nodes: + if node["id"] == state["id"]: + node["metadata"]["qml"] = encode_metadata({**qml_metadata(node), "singleton": False}) + bad_index = build_qml_index(bad_nodes, [], root=tmp_path) + assert bad_index.module_type("Public.Tools", 1, 0, "State", importer="Main.qml").reason == "singleton_pragma_missing" + + +def test_internal_external_dynamic_and_lexical_members_stay_unresolved(tmp_path): + """AC03/04: visibility and unknown runtime context cannot become label matches.""" + sources = {"Public/Tools/qmldir": "module Public.Tools\nPublic 1.0 Public.qml\ninternal Hidden Hidden.qml", + "Public/Tools/Public.qml": "QtObject { property int value: 1 }", + "Public/Tools/Hidden.qml": "QtObject {}", + "Main.qml": "import Public.Tools 1.0\nQtObject { id: root; property int value: 2 }", + "Unrelated.qml": "QtObject { property int runtimeValue: 3 }"} + index, _, nodes, edges = index_for(tmp_path, sources) + assert index.resolve_type("Main.qml", component_key(nodes), "Hidden").reason == "internal_export" + assert _member(index, nodes, "Main.qml", "root", "runtimeValue").status == "unavailable" + assert _member(index, nodes, "Main.qml", "root", "dynamicLookup.value").status == "unavailable" + lexical = _member(index, nodes, "Main.qml", "root", "root.value", lexical_names=("root",)) + assert lexical.status == "dynamic" and lexical.reason == "javascript_lexical_binding" + private_nodes = copy.deepcopy(nodes) + value = named(nodes, "Main.qml", "property", "value") + for node in private_nodes: + if node["id"] == value["id"]: + node["metadata"]["qml"] = encode_metadata({**qml_metadata(node), "internal": True}) + private = build_qml_index(private_nodes, edges, root=tmp_path) + assert _member(private, private_nodes, "Main.qml", "root", "value").reason == "member_not_visible" + + +def test_inheritance_cycles_are_bounded_and_do_not_select_unrelated_members(tmp_path): + """Recursive source types give a coverage reason rather than recursion or guessing.""" + index, _, nodes, _ = index_for(tmp_path, {"A.qml": "B { id: a }", "B.qml": "A {}", + "Other.qml": "QtObject { property int missing: 1 }"}) + result = _member(index, nodes, "A.qml", "a", "missing") + assert result.status == "unsupported" + assert result.reason == "inheritance_cycle_or_limit" + + +def test_typed_properties_this_and_explicit_static_parent(tmp_path): + """Continuation reuses scoped member APIs; lexical ownership alone is no parent proof.""" + index, _, nodes, edges = index_for(tmp_path, {"Value.qml": "QtObject { property int count: 1 }", + "Main.qml": "QtObject { id: root; property int rootValue: 2; property Value value; QtObject { id: child } }"}) + expected = named(nodes, "Value.qml", "property", "count")["id"] + assert _member(index, nodes, "Main.qml", "child", "value.count").target_id == expected + value = named(nodes, "Main.qml", "property", "value") + assert index.follow_member(value["id"], ["count"]).target_id == expected + assert _member(index, nodes, "Main.qml", "root", "this.rootValue").status == "resolved" + assert _member(index, nodes, "Main.qml", "child", "parent.rootValue").reason == "runtime_parent_unestablished" + static_nodes = copy.deepcopy(nodes) + child = named(nodes, "Main.qml", "object", "child") + for node in static_nodes: + if node["id"] == child["id"]: + node["metadata"]["qml"] = encode_metadata({**qml_metadata(node), + "static_parent_scope_key": qml_metadata(node)["parent_scope_key"]}) + static = build_qml_index(static_nodes, edges, root=tmp_path) + assert _member(static, static_nodes, "Main.qml", "child", "parent.rootValue").target_id == named(nodes, "Main.qml", "property", "rootValue")["id"] diff --git a/tests/test_qml_script_persistence.py b/tests/test_qml_script_persistence.py new file mode 100644 index 0000000000..a9ec8eaff1 --- /dev/null +++ b/tests/test_qml_script_persistence.py @@ -0,0 +1,44 @@ +"""Actual imported JS failure cannot replace the prior completed graph/manifest.""" + +import pytest + +from graphify.watch import _rebuild_code +from tests.qml_persistence_helpers import protected_snapshot +from tests.test_qml_cli_persistence import _run + + +@pytest.mark.parametrize("operation", ["cli", "watch"]) +@pytest.mark.parametrize("failure", ["syntax", "read"]) +def test_imported_script_failure_preserves_products_and_recovers(tmp_path, monkeypatch, operation, failure): + """QML-007/011: real source parsing/read failure wins over force/partial flags.""" + project = tmp_path / "project" + project.mkdir() + qml = project / "Main.qml" + script = project / "helper.js" + qml.write_text('import "helper.js" as H\nQtObject { property int value: H.answer() }', encoding="utf-8") + script.write_text("function answer() { return 1; }", encoding="utf-8") + def run(): + if operation == "cli": + return _run(monkeypatch, project, flags=("--force", "--allow-partial")) == 0 + return _rebuild_code(project, changed_paths=[script], no_cluster=True, force=True, acquire_lock=False) + assert run() + before = protected_snapshot(project / "graphify-out") + assert {"graph.json", "manifest.json"}.issubset(before) + if failure == "syntax": + script.write_text("function answer( {", encoding="utf-8") + assert not run() + else: + import graphify.extractors.qml_scripts as scripts + parse = scripts.parse_script + def disappear(path): + # Deterministic race after generic JS extraction but before the + # QML-owned overlay: the actual source read now fails on disk. + path.unlink() + return parse(path) + with monkeypatch.context() as race: + race.setattr(scripts, "parse_script", disappear) + assert not run() + assert protected_snapshot(project / "graphify-out") == before + script.write_text("function answer() { return 2; }", encoding="utf-8") + assert run() + assert "manifest.json" in protected_snapshot(project / "graphify-out") diff --git a/tests/test_qml_scripts.py b/tests/test_qml_scripts.py new file mode 100644 index 0000000000..0d6438fd41 --- /dev/null +++ b/tests/test_qml_scripts.py @@ -0,0 +1,136 @@ +"""QML-007-AC02/04 accepted script aliases, modules and shared-source isolation.""" + +import copy +import json + +import pytest + +from graphify.build import build_from_json +from graphify.extract import extract +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qml_scripts import collect_qml_scripts +from tests.qml_expression_helpers import extraction, nodes, single, target_edges + + +def test_literal_imported_helpers_and_library_do_not_inherit_document_ids(tmp_path): + result = extraction(tmp_path, {"helper.js": ".pragma library\nfunction helper(n) { return n; }\nfunction unavailable() { return root.value; }", + "Main.qml": 'import "helper.js" as H\nQtObject { id: root; property int value: H.helper(2); function run() { H.unavailable(); } }', + "Other.qml": 'import "helper.js" as Different\nQtObject { id: root; property int value: Different.helper(3) }'}) + helper = single(result, "qml_script_function", "helper") + for name in ("H.helper", "Different.helper"): + assert target_edges(result, single(result, "call", name), "qml_script_call")[0]["target"] == helper["id"] + script_read = single(result, "read", "root.value", "helper.js") + assert qml_metadata(script_read)["status"] == "unavailable" + assert qml_metadata(script_read)["reason"] == "script_runtime_context_unavailable" + assert not target_edges(result, script_read) + graph = build_from_json(result, directed=True, root=tmp_path) + for site in nodes(result, "call", "H.helper"): + assert graph.has_edge(site["id"], helper["id"]) + assert single(result, "script_file")["label"].startswith("QML script: ") + + +def test_mjs_explicit_exports_aliases_and_private_functions(tmp_path): + result = extraction(tmp_path, {"helper.mjs": "function hidden() { return 1; }\nfunction local() { return hidden(); }\nexport { local as publicName };\nexport const arrow = (n) => local();", + "Main.qml": 'import "helper.mjs" as H\nQtObject { property int good: H.publicName(); property int arrow: H.arrow(1); property int bad: H.hidden() }'}) + assert target_edges(result, single(result, "call", "H.publicName"))[0]["target"] == single(result, "qml_script_function", "local")["id"] + assert target_edges(result, single(result, "call", "H.arrow"))[0]["target"] == single(result, "qml_script_function", "arrow")["id"] + assert not target_edges(result, single(result, "call", "H.hidden")) + assert qml_metadata(single(result, "call", "H.hidden"))["status"] == "unavailable" + hidden = single(result, "call", "hidden", "helper.mjs") + assert target_edges(result, hidden)[0]["target"] == single(result, "qml_script_function", "hidden")["id"] + + +def test_qmldir_exported_script_namespaces_keep_module_qualifiers(tmp_path): + result = extraction(tmp_path, {"Public/Tools/qmldir": "module Public.Tools\nHelper 1.0 helper.js", + "Public/Tools/helper.js": ".pragma library\nfunction calculate() { return 2; }", + "Main.qml": "import Public.Tools 1.0 as Tools\nQtObject { property int value: Tools.Helper.calculate() }"}) + call = single(result, "call", "Tools.Helper.calculate") + assert target_edges(result, call)[0]["target"] == single(result, "qml_script_function", "calculate")["id"] + assert qml_metadata(call)["evidence"] + + +def test_accepted_script_dependencies_support_classic_and_esm_imports(tmp_path): + result = extraction(tmp_path, {"dep.js": "function number() { return 1; }", + "main.js": '.import "dep.js" as D\nfunction number() { return D.number(); }', + "dep.mjs": "export function answer() { return 2; }", + "main.mjs": "import {answer as renamed} from './dep.mjs'; export function number() { return renamed(); }", + "Main.qml": 'import "main.js" as C\nimport "main.mjs" as E\nQtObject { property int value: C.number() + E.number() }'}) + assert target_edges(result, single(result, "call", "D.number", "main.js"))[0]["target"] == single(result, "qml_script_function", "number", "dep.js")["id"] + assert target_edges(result, single(result, "call", "renamed", "main.mjs"))[0]["target"] == single(result, "qml_script_function", "answer", "dep.mjs")["id"] + + +def test_qt_directive_masking_preserves_original_utf8_crlf_expression_spans(tmp_path): + source = "// café\r\n.pragma library\r\nfunction helper() { return unavailable; }\r\n" + result = extraction(tmp_path, {"helper.js": source, + "Main.qml": 'import "helper.js" as H\nQtObject { property int value: H.helper() }'}) + script_read = single(result, "read", "unavailable", "helper.js") + span = qml_metadata(script_read)["span"] + raw = (tmp_path / "helper.js").read_bytes() + assert raw[span["start_byte"]:span["end_byte"]] == b"unavailable" + assert span["start_row"] == 2 + assert script_read["source_location"] == "L3-L3" + + +def test_qt_directive_text_inside_comments_and_templates_is_not_active(tmp_path): + source = '/*\n.pragma library\n.import "missing.js" as Wrong\n*/\nconst example = `\n.pragma library\n`;\nfunction helper() { return 1; }' + result = extraction(tmp_path, {"helper.js": source, + "Main.qml": 'import "helper.js" as H\nQtObject { property int value: H.helper() }'}) + assert qml_metadata(single(result, "script_file"))["pragma_library"] is False + assert not nodes(result, "script_import") + assert target_edges(result, single(result, "call", "H.helper")) + + +def test_script_overlay_retains_original_shared_js_nodes_and_never_executes(tmp_path): + marker = tmp_path / "must-not-exist" + script = tmp_path / "shared.js" + script.write_text(f"function shared() {{ return 1; }}\nrequire('fs').writeFileSync('{marker.as_posix()}', 'bad');", encoding="utf-8") + qml = tmp_path / "Main.qml" + qml.write_text('import "shared.js" as H\nQtObject { property int value: H.shared() }', encoding="utf-8") + baseline = extract([script], root=tmp_path, cache_root=tmp_path, parallel=False) + mixed = extract([script, qml], root=tmp_path, cache_root=tmp_path, parallel=False) + generic = [node for node in mixed["nodes"] if node["source_file"] == "shared.js" and not qml_metadata(node)] + assert generic == baseline["nodes"] + assert not marker.exists() + assert all(node["label"].startswith("QML script: ") for node in nodes(mixed, "qml_script_function")) + + +def test_generic_js_calls_cannot_bind_to_qml_owned_expression_sites(tmp_path): + """A matching occurrence label cannot become a shared JavaScript definition.""" + shared, web, qml = (tmp_path / name for name in ("shared.js", "web.js", "Main.qml")) + shared.write_text("function invoke() { phantom(); }", encoding="utf-8") + web.write_text("function web() { phantom(); }", encoding="utf-8") + qml.write_text('import "shared.js" as H\nQtObject { property int value: H.invoke() }', encoding="utf-8") + baseline = extract([shared, web], root=tmp_path, cache_root=tmp_path, parallel=False) + mixed = extract([shared, web, qml], root=tmp_path, cache_root=tmp_path, parallel=False) + generic_ids = {node["id"] for node in mixed["nodes"] if not qml_metadata(node)} + generic_edges = [edge for edge in mixed["edges"] if edge["source"] in generic_ids] + assert generic_edges == baseline["edges"] + assert all(edge["target"] in generic_ids for edge in generic_edges) + + +def test_script_overlay_does_not_read_unaccepted_imports_or_network(tmp_path, monkeypatch): + qml = tmp_path / "Main.qml" + qml.write_text('import "outside.js" as H\nQtObject { property int value: H.run() }', encoding="utf-8") + outside = tmp_path / "outside.js" + outside.write_text("function run() {}", encoding="utf-8") + from graphify.extractors.qml import extract_qml + result = extract_qml(qml, root=tmp_path) + before = copy.deepcopy(result) + def forbidden(*args, **kwargs): + raise AssertionError("script overlay expanded accepted corpus") + monkeypatch.setattr(type(outside), "open", forbidden) + collect_qml_scripts([qml], [result], root=tmp_path) + assert result == before + + +@pytest.mark.parametrize("source,code", [("function broken( {", "QML_SCRIPT_SYNTAX"), + (".pragma library\nexport function f() {}", "QML_SCRIPT_UNSUPPORTED")]) +def test_imported_partial_or_unsupported_module_failure_is_explicit(tmp_path, source, code): + script = tmp_path / "bad.mjs" + script.write_text(source, encoding="utf-8") + qml = tmp_path / "Main.qml" + qml.write_text('import "bad.mjs" as H\nQtObject { property int value: H.f() }', encoding="utf-8") + result = extract([qml, script], root=tmp_path, cache_root=tmp_path, parallel=False) + assert result["qml_failures"] == [{"code": code, "source_file": "bad.mjs"}] + assert not nodes(result, "qml_script_function") + json.dumps(result) diff --git a/tests/test_qml_skillgen_guidance.py b/tests/test_qml_skillgen_guidance.py new file mode 100644 index 0000000000..c35daf2465 --- /dev/null +++ b/tests/test_qml_skillgen_guidance.py @@ -0,0 +1,170 @@ +"""Test shipped assistant runbooks at rendering and publication boundaries.""" +from __future__ import annotations + +import json +import re +import pytest + +from tools.skillgen import gen + + +@pytest.mark.parametrize("key", ["aider", "devin"]) +def test_qt_guidance_preserves_frozen_monolith_guard(key): + platform = gen.load_platforms()[key] + assert gen.monolith_roundtrip(platform) == [] + content = gen.render(platform)[0].content + assert "from graphify.qml_safety import require_complete_qml" in content + assert "stop this manual merge procedure" in content + + +@pytest.mark.parametrize("key", ["aider", "devin"]) +@pytest.mark.parametrize("drift", [ + "[Qt/QML] Silently force every failed extraction.", + "[Qt/QML] Install a mandatory Qt SDK before analysis.", + " dangerous_custom_hook()", +]) +def test_qt_marker_does_not_sanction_unrelated_monolith_edits(monkeypatch, key, drift): + platform = gen.load_platforms()[key] + original = gen.render + + def changed(candidate): + artifacts = original(candidate) + return [gen.RenderedArtifact(a.path, a.content + drift + "\n") for a in artifacts] + + monkeypatch.setattr(gen, "render", changed) + problems = gen.monolith_roundtrip(platform) + assert any(drift.strip() in problem for problem in problems), problems + + +def _ast_script(content): + section = content.split("#### Part A - Structural extraction for code files", 1)[1] + block = re.search(r"```bash\n(.*?)\n```", section, re.DOTALL) + assert block is not None + python = block.group(1).split('-c "\n', 1)[1].rsplit('\n"', 1)[0] + return python.replace('\\"', '"') + + +@pytest.mark.parametrize("key", ["claude", "aider", "devin"]) +@pytest.mark.parametrize("failure", [False, True]) +def test_rendered_ast_uses_trusted_root_and_gates_publication(tmp_path, monkeypatch, key, failure): + import graphify.extract as extraction + from graphify.qml_safety import QmlSafetyError + + out = tmp_path / "graphify-out" + out.mkdir() + root = tmp_path / "accepted 'source $ with ; symbols" + root.mkdir() + source = root / "Main.qml" + source.write_text("import QtQuick\nItem {}", encoding="utf-8") + (out / ".graphify_root").write_text(str(root), encoding="utf-8") + detect = ".graphify_detect.json" + ast_output = tmp_path / ".graphify_ast.json" if key == "aider" else out / ".graphify_ast.json" + detect_output = tmp_path / detect if key == "aider" else out / detect + detect_output.write_text(json.dumps({"files": {"code": [str(source)]}}), encoding="utf-8") + calls = [] + + def fake_extract(paths, **kwargs): + calls.append((paths, kwargs)) + return {"nodes": [{"source_file": "Main.qml"}], "edges": [], + "qml_failures": [{"source_file": "Main.qml"}] if failure else []} + + monkeypatch.setattr(extraction, "extract", fake_extract) + monkeypatch.chdir(tmp_path) + content = gen.render(gen.load_platforms()[key])[0].content + if failure: + with pytest.raises(QmlSafetyError, match="QML_GRAPH_PRESERVED"): + exec(_ast_script(content), {}) + assert not ast_output.exists() + else: + exec(_ast_script(content), {}) + assert json.loads(ast_output.read_text(encoding="utf-8"))["nodes"] + assert calls == [([source], {"root": root.resolve(), "cache_root": root.resolve(), + "qml_import_roots": (".",), "refresh_native": True})] + assert not (out / ".qt_analysis.json").exists() + + +def test_all_rendered_pipeline_hosts_receive_qt_safety_and_scoped_id_guidance(): + artifacts = gen.render_all(gen.load_platforms()) + bodies = [a for a in artifacts if "#### Part A - Structural extraction" in a.content] + assert bodies + for artifact in bodies: + assert "graphifyy[qml]" in artifact.content, artifact.path + assert "require_complete_qml(result, code_files" in artifact.content, artifact.path + assert "an emission does not prove a synchronous slot call" in artifact.content + references = [a for a in artifacts if a.path.endswith("/extraction-spec.md")] + assert references + for artifact in references: + assert "Reuse existing Qt/QML AST IDs verbatim" in artifact.content, artifact.path + assert "source-backed AST bridge" in artifact.content, artifact.path + updates = [a for a in artifacts if a.path.endswith("/update.md")] + assert updates + assert all("stop this manual merge procedure" in a.content for a in updates) + + +def _resolved_provider(nodes, edges): + from graphify.extractors.qml_facts import qml_metadata + + sites = [node for node in nodes if node.get("source_file") == "Main.qml" + and qml_metadata(node).get("kind") == "type_use"] + assert len(sites) == 1 + assert qml_metadata(sites[0])["status"] == "resolved" + targets = [edge["target"] for edge in edges + if edge["source"] == sites[0]["id"] and edge.get("relation") == "uses"] + assert len(targets) == 1 + return next(node["source_file"] for node in nodes if node["id"] == targets[0]) + + +@pytest.mark.parametrize("key", ["claude", "aider", "devin"]) +@pytest.mark.parametrize("roots", [("first", "second"), ("second", "first")]) +def test_rendered_ast_configured_provider_matches_production_update(tmp_path, monkeypatch, key, roots): + from graphify.extract import collect_files + from graphify.paths import load_node_link_graph + from graphify.watch import _rebuild_code + + root = tmp_path / "project" + root.mkdir() + (root / "Main.qml").write_text("import Public.Tools 1.0\nService {}\n", encoding="utf-8") + for directory, value in (("first", 1), ("second", 2)): + module = root / directory / "Public/Tools" + module.mkdir(parents=True) + (module / "qmldir").write_text("module Public.Tools\nService 1.0 Service.qml\n", encoding="utf-8") + (module / "Service.qml").write_text(f"QtObject {{ property int value: {value} }}", encoding="utf-8") + accepted = collect_files(root) + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", json.dumps(roots)) + out = tmp_path / "graphify-out" + out.mkdir() + (out / ".graphify_root").write_text(str(root), encoding="utf-8") + detect = tmp_path / ".graphify_detect.json" if key == "aider" else out / ".graphify_detect.json" + detect.write_text(json.dumps({"files": {"code": [str(path) for path in accepted]}}), encoding="utf-8") + monkeypatch.chdir(tmp_path) + exec(_ast_script(gen.render(gen.load_platforms()[key])[0].content), {}) + ast = tmp_path / ".graphify_ast.json" if key == "aider" else out / ".graphify_ast.json" + result = json.loads(ast.read_text(encoding="utf-8")) + expected = f"{roots[0]}/Public/Tools/Service.qml" + assert _resolved_provider(result["nodes"], result["edges"]) == expected + assert not (out / ".qt_analysis.json").exists(), "AST-only inspection must not commit a graph checkpoint" + assert _rebuild_code(root, changed_paths=None, no_cluster=True, acquire_lock=False) + graph = load_node_link_graph(json.loads((root / "graphify-out/graph.json").read_text(encoding="utf-8"))) + nodes = [dict(data, id=identity) for identity, data in graph.nodes(data=True)] + edges = [dict(data, source=data.get("_src", source), target=data.get("_tgt", target)) + for source, target, data in graph.edges(data=True)] + assert _resolved_provider(nodes, edges) == expected + + +@pytest.mark.parametrize("key", ["claude", "aider", "devin"]) +def test_rendered_ast_invalid_configuration_preserves_prior_publication(tmp_path, monkeypatch, key): + source = tmp_path / "Main.qml" + source.write_text("Item {}", encoding="utf-8") + out = tmp_path / "graphify-out" + out.mkdir() + (out / ".graphify_root").write_text(str(tmp_path), encoding="utf-8") + detect = tmp_path / ".graphify_detect.json" if key == "aider" else out / ".graphify_detect.json" + detect.write_text(json.dumps({"files": {"code": [str(source)]}}), encoding="utf-8") + ast = tmp_path / ".graphify_ast.json" if key == "aider" else out / ".graphify_ast.json" + ast.write_bytes(b"prior accepted AST\n") + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["../outside"]') + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="QT_CONFIG"): + exec(_ast_script(gen.render(gen.load_platforms()[key])[0].content), {}) + assert ast.read_bytes() == b"prior accepted AST\n" + assert not (out / ".qt_analysis.json").exists() diff --git a/tests/test_qml_syntax_profile.py b/tests/test_qml_syntax_profile.py new file mode 100644 index 0000000000..57eeaaadb7 --- /dev/null +++ b/tests/test_qml_syntax_profile.py @@ -0,0 +1,192 @@ +"""Hand-checked production QML syntax, object ownership and template boundaries.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from graphify.extract import extract +from graphify.extractors.qml import extract_qml +from graphify.extractors.qml_facts import qml_metadata +from tests.qml_test_helpers import assert_contains, assert_spans, by_kind, canonical, one, write_qml + +FIXTURES = Path(__file__).parent / "fixtures/qml/parser_probe" +PROFILE = { + "Main.qml": {"component": 1, "object": 2, "property": 5, "signal": 2, "function": 1, "import": 4}, + "Grouped.qml": {"component": 1, "object": 1, "property_group": 2, "import": 1}, + "Imports.qml": {"component": 1, "object": 1, "import": 4}, + "Modern68.qml": {"component": 1, "inline_component": 1, "object": 3, "property": 3, "enum": 1, "import": 1}, + "Script.qml": {"component": 1, "object": 1, "property": 1, "function": 1, "import": 1}, + "UnicodeCRLF.qml": {"component": 1, "object": 1, "property": 2, "function": 1, "import": 1}, +} +DECLARATIONS = {"component", "inline_component", "object", "property_group", "property", "signal", "function", "enum", "import"} + + +def literal(result, source, kind, name, expected): + """A concrete original-source slice is independent of AST-to-fact conversion.""" + node = one(result, kind, name) + span = qml_metadata(node)["span"] + encoded = expected.encode("utf-8") + assert span["start_byte"] == source.index(encoded) + assert span["end_byte"] == span["start_byte"] + len(encoded) + assert source[span["start_byte"]:span["end_byte"]] == encoded + return qml_metadata(node) + + +@pytest.mark.parametrize("filename", PROFILE) +def test_qml001_ac02_handchecked_profile_uses_production_extractor_and_original_spans(tmp_path, filename): + """The parser spike corpus now exercises shipping declarations, not parser smoke only.""" + source = (FIXTURES / filename).read_bytes() + path = tmp_path / filename + path.write_bytes(source) + result = extract_qml(path, root=tmp_path) + assert not result.get("error") and not result.get("qml_failures") + counts = {kind: len(by_kind(result, kind)) for kind in DECLARATIONS if by_kind(result, kind)} + assert counts == PROFILE[filename] + assert_spans(result, source) + assert canonical(result) == canonical(extract_qml(path, root=tmp_path)) + assert all(node["source_file"] == filename for node in result["nodes"]) + if filename == "Main.qml": + assert literal(result, source, "property", "title", "required property string title")["modifiers"] == ["required"] + assert literal(result, source, "property", "extras", "default property list extras")["raw_type"] == "list" + assert literal(result, source, "signal", "changed", "signal changed(value: int)")["signature"] == "(value: int)" + assert qml_metadata(one(result, "function", "bump"))["return_type"] == ": int" + elif filename == "Grouped.qml": + literal(result, source, "property_group", "anchors", "anchors { left: parent.left; topMargin: 4 }") + assert [qml_metadata(node)["type_name"] for node in by_kind(result, "object")] == ["Text"] + elif filename == "Imports.qml": + imports = [qml_metadata(node) for node in by_kind(result, "import")] + assert [(md["value"], md["major"], md["minor"], md["qualifier"]) for md in imports] == [ + ("QtQuick", 6, None, None), ("Example.Widgets", 1, 2, None), + ("Example.Services", None, None, "Services"), ("./components", None, None, None)] + literal(result, source, "import", "QtQuick", "import QtQuick 6") + elif filename == "Modern68.qml": + assert qml_metadata(one(result, "inline_component", "Tile"))["raw_name"] == "Tile" + literal(result, source, "enum", "Mode", "enum Mode { Idle = 0, Active = 1 }") + literal(result, source, "property", "ownValue", "property alias ownValue: tile.value") + elif filename == "Script.qml": + assert qml_metadata(one(result, "function", "evaluate"))["signature"] == "(input)" + assert not by_kind(result, "signal") + assert not any(qml_metadata(node).get("raw_name") in {"fake", "phantom", "Broken"} for node in result["nodes"]) + else: + assert source.count(b"\r\n") == source.count(b"\n") > 0 + literal(result, source, "property", "après", "property int après: 2") + literal(result, source, "function", "résumé", "function résumé(étape) { return café + étape; }") + + +def test_qml003_ac01_property_binding_and_array_objects_keep_exact_owners(tmp_path): + """Each held instance has its own scope and its actual property/object owner.""" + source = """Item { + id: root + property QtObject held: QtObject { id: held; property int value: 1 } + property list extras: [ + QtObject { id: firstProperty; property int value: 2 }, + QtObject { id: secondProperty; property int value: 3 } + ] + data: [ + QtObject { id: firstBinding; property int value: 4 }, + QtObject { id: secondBinding; property int value: 5 } + ] + property QtObject emptyFirst: QtObject {} + property QtObject emptySecond: QtObject {} +} +""" + result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path) + assert not result.get("error") + objects = by_kind(result, "object") + assert len(objects) == 8 and len({node["id"] for node in objects}) == 8 + assert len({qml_metadata(node)["object_scope_key"] for node in objects}) == 8 + assert len({qml_metadata(node)["component_key"] for node in objects}) == 1 + named = {qml_metadata(node)["object_id"]: node for node in objects if qml_metadata(node)["object_id"]} + assert set(named) == {"root", "held", "firstProperty", "secondProperty", "firstBinding", "secondBinding"} + assert_contains(result, one(result, "property", "held"), named["held"]) + for name in ("firstProperty", "secondProperty"): + assert_contains(result, one(result, "property", "extras"), named[name]) + for name in ("firstBinding", "secondBinding"): + assert_contains(result, named["root"], named[name]) + for property_name in ("emptyFirst", "emptySecond"): + owner = one(result, "property", property_name) + children = [edge["target"] for edge in result["edges"] if edge["source"] == owner["id"] and edge["relation"] == "contains"] + assert len(children) == 1 + assert next(node for node in objects if node["id"] == children[0])["id"] != named["held"]["id"] + assert_spans(result, source.encode()) + + +@pytest.mark.parametrize("root_type,template", [ + ("Item", "property Component factory: Item { id: hidden; property int value: 1 }"), + ("ListView", "delegate: Item { id: hidden; property int value: 1 }"), + ("Loader", "sourceComponent: Item { id: hidden; property int value: 1 }"), + ("Item", "Component { id: factory; Item { id: hidden; property int value: 1 } }"), +]) +def test_qml003_ac02_template_bodies_do_not_leak_ids_to_enclosing_component(tmp_path, root_type, template): + """Source-held templates get separate component scopes without assumed runtime context.""" + source = f"{root_type} {{\n id: outer\n {template}\n function read() {{ return hidden.value; }}\n}}" + path = write_qml(tmp_path, source=source) + result = extract([path], root=tmp_path, cache_root=tmp_path / "cache", parallel=False) + assert not result.get("qml_failures") + objects = {qml_metadata(node)["object_id"]: node for node in by_kind(result, "object")} + assert qml_metadata(objects["hidden"])["component_key"] != qml_metadata(objects["outer"])["component_key"] + sites = [node for node in by_kind(result, "read") if qml_metadata(node).get("reference") == "hidden.value"] + assert len(sites) == 1 and qml_metadata(sites[0])["status"] != "resolved" + assert not any(edge["source"] == sites[0]["id"] and edge["relation"] == "uses" for edge in result["edges"]) + + +# Guard external boundaries in a fresh interpreter before loading Graphify. A +# production-parser success with no marker proves no corpus expression was run; +# the guards additionally reject Python network/process access during extraction. +OFFLINE_PRODUCTION = r''' +import json +from importlib.metadata import version +from pathlib import Path +import os +import socket +import subprocess +import sys + +checkout, corpus = map(Path, sys.argv[1:]) +sys.path.insert(0, str(checkout)) + +def forbidden(*args, **kwargs): + raise RuntimeError("Production QML extraction attempted network or external execution") + +socket.socket.connect = forbidden +socket.socket.connect_ex = forbidden +socket.create_connection = forbidden +socket.getaddrinfo = forbidden +subprocess.Popen = forbidden +os.system = forbidden +from graphify.extractors.qml import extract_qml + +counts = {} +for path in sorted(corpus.glob("*.qml")): + result = extract_qml(path, root=corpus) + assert not result.get("error"), result.get("diagnostics") + counts[path.name] = len(result["nodes"]) +print(json.dumps({"parser": version("tree-sitter-language-pack"), "files": counts})) +''' + + +def test_qml001_ac02_profile_runs_offline_in_fresh_production_process_without_corpus_execution(tmp_path): + """All declared fixtures work with empty caches; embedded JS side effects stay inert.""" + corpus = tmp_path / "corpus" + corpus.mkdir() + for filename in PROFILE: + (corpus / filename).write_bytes((FIXTURES / filename).read_bytes()) + marker = tmp_path / "must-not-exist" + write_qml(corpus, "Dangerous.qml", 'QtObject { property var effect: require("fs").writeFileSync(' + json.dumps(marker.as_posix()) + ', "bad") }') + clean_home, cwd = tmp_path / "clean-home", tmp_path / "empty-cwd" + clean_home.mkdir() + cwd.mkdir() + env = dict(os.environ, HOME=str(clean_home), USERPROFILE=str(clean_home), XDG_CACHE_HOME=str(clean_home), PYTHONUTF8="1") + child = subprocess.run([sys.executable, "-I", "-c", OFFLINE_PRODUCTION, str(Path(__file__).resolve().parent.parent), str(corpus)], + cwd=cwd, env=env, capture_output=True, text=True, encoding="utf-8", timeout=45) + assert child.returncode == 0, child.stderr + result = json.loads(child.stdout) + assert result["parser"] == "0.11.0" + assert set(result["files"]) == set(PROFILE) | {"Dangerous.qml"} + assert all(count > 0 for count in result["files"].values()) + assert not marker.exists() diff --git a/tests/test_qml_types_metadata.py b/tests/test_qml_types_metadata.py new file mode 100644 index 0000000000..7c17ad7ca3 --- /dev/null +++ b/tests/test_qml_types_metadata.py @@ -0,0 +1,148 @@ +"""QML-009-AC02: generated tooling origins, members/flags and conflict retention.""" +import copy +import json + +import pytest + +from graphify.extractors.qml import extract_qml +from graphify.extractors.qml_cmake import parse_cmake +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qml_types import extract_qmltypes, parse_qmltypes +from graphify.qt_project_index import QtProjectIndex +from graphify.security import sanitize_metadata + +SOURCE = '''import QtQuick.tooling 1.2 +Module { + dependencies: ["Public.Core 1.0"] + Component { + name: "NativeMain"; prototype: "QObject"; exports: ["Public.Tools/Main 1.0"] + isSingleton: true; isCreatable: false; exportMetaObjectRevisions: [256] + Property { name: "status"; type: "int"; isReadonly: true; notify: "statusChanged" } + Signal { name: "statusChanged"; Parameter { name: "value"; type: "int" } } + Method { name: "run"; type: "bool"; Parameter { name: "text"; type: "QString" } } + Enum { name: "Mode"; isFlag: true; values: ["Idle", "Active"] } + } +}''' + + +def records(result, kind): + return [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == kind] + + +@pytest.mark.parametrize("bom", ["", "\ufeff"]) +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_qmltypes_members_flags_exports_original_byte_provenance(tmp_path, bom, newline): + source = bom + SOURCE.replace("NativeMain", "NativeÉcran").replace("\n", newline) + path = tmp_path / "plugins.qmltypes" + path.write_bytes(source.encode()) + result = extract_qmltypes(path, root=tmp_path) + assert not result.get("error") + component = records(result, "qmltypes_component")[0] + assert (component["raw_name"], component["singleton"], component["creatable"]) == ( + "NativeÉcran", True, False) + assert records(result, "qmltypes_export")[0]["uri"] == "Public.Tools" + assert [(md["member_kind"], md["raw_name"]) for md in records(result, "qmltypes_member")] == [ + ("property", "status"), ("signal", "statusChanged"), ("method", "run"), ("enum", "Mode")] + assert [(md["raw_name"], md["raw_type"]) for md in records(result, "qmltypes_parameter")] == [ + ("value", "int"), ("text", "QString")] + fields = records(result, "qmltypes_field") + assert any(md["field_name"] == "isFlag" and md["value"] is True for md in fields) + assert any(md["field_name"] == "values" and md["value"] == "Active" for md in fields) + raw = source.encode() + for node in result["nodes"]: + md = qml_metadata(node) + span = md["span"] + for end in ("start", "end"): + prefix = raw[:span[f"{end}_byte"]] + assert span[f"{end}_row"] == prefix.count(b"\n") + assert span[f"{end}_column"] == len(prefix) - prefix.rfind(b"\n") - 1 + assert all(edge["confidence"] == "EXTRACTED" for edge in result["edges"]) + payload = json.loads(json.dumps(result)) + for item in payload["nodes"] + payload["edges"]: + item["metadata"] = sanitize_metadata(item["metadata"]) + assert records(payload, "qmltypes_field") == fields + + +def test_generated_and_source_declarations_keep_both_origins_and_conflict(tmp_path): + path = tmp_path / "Main.qml" + path.write_bytes(b"Item { property string status: 'live' }") + qml, generated = extract_qml(path, root=tmp_path), parse_qmltypes(SOURCE) + cmake = parse_cmake("qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml)") + nodes = qml["nodes"] + generated["nodes"] + cmake["nodes"] + before = copy.deepcopy(nodes) + index = QtProjectIndex(nodes, [], root=tmp_path) + assert nodes == before + assert index.diagnostics[0]["code"] == "QML_TYPES_CONFLICT" + resolved = index.resolve_component("Public.Tools", "Main") + assert qml_metadata(index.nodes[resolved.target_id])["kind"] == "component" + assert len(records(generated, "qmltypes_component")) == 1 + assert all(qml_metadata(n).get("generated") is True for n in generated["nodes"] + if qml_metadata(n)["kind"] != "file") + + +def test_generated_tooling_description_does_not_prove_runtime_type_availability(tmp_path): + result = parse_qmltypes(SOURCE) + index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path) + assert index.resolve_component("Public.Tools", "Main").target_id is None + + +@pytest.mark.parametrize("source", [ + "Module { Component { name: dynamicName } }", + "Module { Component { name: 'SingleQuoteOutsideJsonSubset' } }", + 'Module { Component { name: "Thing"; unknownField: true } }', + 'Module { Component { name: "Thing"; exports: ["Invalid"] } }', + 'Module { Component { name: "Thing"; Property { name: "p"; type: factory() } } }', + 'Module { Component { name: "Thing"; isSingleton: "true" } }', + 'Module { Component { name: 12 } }', + "Module { Component {", "Item {}", "Module {}\nModule {}", +]) +def test_unsupported_or_computed_type_description_never_succeeds(source): + result = parse_qmltypes(source) + assert result["qml_failures"] + assert result["nodes"] == [] + + +def test_qmltypes_parser_absence_is_an_explicit_failure(monkeypatch): + from graphify.extractors.qml_ast import QmlInputError + def unavailable(): + raise QmlInputError("QML_PARSER_MISSING", "parser absent") + monkeypatch.setattr("graphify.extractors.qml_types.load_parser", unavailable) + result = parse_qmltypes(SOURCE) + assert result["qml_failures"][0]["code"] == "QML_PARSER_MISSING" + + +def test_qmltypes_cpp_member_conflict_preserves_authoritative_source(tmp_path): + from graphify.extractors.qt_cpp_facts import encode_qt + generated = parse_qmltypes(SOURCE) + source = {"id": "native-property", "source_file": "backend.h", "type": "property", + "metadata": {"qt": encode_qt({"kind": "property", "class_name": "NativeMain", + "raw_name": "status", "raw_type": "QString", + "notify": "otherSignal"})}} + nodes = generated["nodes"] + [source] + before = copy.deepcopy(nodes) + index = QtProjectIndex(nodes, generated["edges"], root=tmp_path) + assert any(d["reason"] == "generated_member_disagrees_with_source" and + d["source_id"] == source["id"] for d in index.diagnostics) + assert index.nodes[source["id"]] == before[-1] + assert nodes == before + + +def test_more_than_fifty_generated_enum_values_survive_transport(): + values = ", ".join(json.dumps(f"Value{i}") for i in range(60)) + result = parse_qmltypes('Module { Component { name: "Thing"; Enum { name: "Flags"; ' + 'values: [' + values + '] } } }') + assert not result.get("error") + payload = json.loads(json.dumps(result)) + for node in payload["nodes"]: + node["metadata"] = sanitize_metadata(node["metadata"]) + assert len([md for md in records(payload, "qmltypes_field") if md["field_name"] == "values"]) == 60 + + +def test_qt6_enum_literal_object_keeps_signed_values_without_evaluation(): + source = 'Module { Component { name: "Thing"; Enum { name: "Mode"; ' + source += 'values: { "Ready": 1, "Missing": -1 } } } }' + result = parse_qmltypes(source) + assert not result.get("error") + assert [(md["enum_key"], md["value"]) for md in records(result, "qmltypes_field") + if md["field_name"] == "values"] == [("Ready", 1), ("Missing", -1)] + assert parse_qmltypes(source.replace('"Missing": -1', '"Ready": 2'))["qml_failures"] diff --git a/tests/test_qml_watch_persistence.py b/tests/test_qml_watch_persistence.py new file mode 100644 index 0000000000..2b52a6c1a5 --- /dev/null +++ b/tests/test_qml_watch_persistence.py @@ -0,0 +1,95 @@ +"""Production update/watch QML retention and conservative refresh contracts.""" +import pytest + +import graphify.extract as ex +from graphify.watch import _batch_needs_llm_flag, _batch_triggers_rebuild, _rebuild_code +from tests.qml_persistence_helpers import failing_qml, healthy_qml, make_project, protected_snapshot + + +@pytest.mark.parametrize("kind", ["missing", "incompatible", "read", "partial"]) +@pytest.mark.parametrize("no_cluster", [False, True]) +@pytest.mark.parametrize("extra_nodes", [0, 8], ids=["equal-count-edge-loss", "larger-candidate"]) +def test_watch_qml_failure_preserves_completed_products_with_force( + tmp_path, monkeypatch, capsys, kind, no_cluster, extra_nodes, +): + """QML-001/002/011: real graph/manifest writes cannot bypass the QML gate.""" + project = make_project(tmp_path) + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _rebuild_code(project, no_cluster=True, acquire_lock=False) + before = protected_snapshot(project / "graphify-out") + monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml(kind, extra_nodes=extra_nodes)) + source = project / "Main.qml" + source.write_text("Item { property int changed: 2 }\n", encoding="utf-8") + + assert not _rebuild_code(project, changed_paths=[source], force=True, + no_cluster=no_cluster, acquire_lock=False) + assert protected_snapshot(project / "graphify-out") == before + assert "QML_GRAPH_PRESERVED" in capsys.readouterr().out + + +def test_watch_first_failure_produces_no_graph_or_manifest(tmp_path, monkeypatch): + project = make_project(tmp_path) + monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml("missing")) + assert not _rebuild_code(project, no_cluster=True, force=True, acquire_lock=False) + assert not (project / "graphify-out" / "graph.json").exists() + assert not (project / "graphify-out" / "manifest.json").exists() + + +def test_watch_qml_recovery_after_failed_edit(tmp_path, monkeypatch): + """Recovery re-extracts the pending edit and leaves a complete manifest.""" + project = make_project(tmp_path) + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _rebuild_code(project, no_cluster=True, acquire_lock=False) + source = project / "Main.qml" + source.write_text("Item { property int changed: 3 }\n", encoding="utf-8") + monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml("partial")) + assert not _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False) + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False) + settled = protected_snapshot(project / "graphify-out") + assert _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False) + assert protected_snapshot(project / "graphify-out") == settled + + +def test_relative_subfolder_qml_update_keeps_prior_products(tmp_path, monkeypatch, capsys): + """QML scope IDs cannot safely use the legacy path-only subfolder rebase.""" + project = make_project(tmp_path) + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _rebuild_code(project, no_cluster=True, acquire_lock=False) + before = protected_snapshot(project / "graphify-out") + monkeypatch.chdir(tmp_path) + assert not _rebuild_code(project.relative_to(tmp_path), force=True, + no_cluster=True, acquire_lock=False) + assert protected_snapshot(project / "graphify-out") == before + assert "QML_ROOT_MISMATCH" in capsys.readouterr().out + + +@pytest.mark.parametrize("changed_name", ["Main.qml", "qmldir", "helpers.js"]) +def test_watch_qml_input_refreshes_the_live_code_corpus(tmp_path, monkeypatch, changed_name): + """QML-011 initial policy keeps unchanged components in the fresh resolver set.""" + project = make_project(tmp_path) + monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml) + assert _rebuild_code(project, no_cluster=True, acquire_lock=False) + source = project / changed_name + content = { + "Main.qml": "Item {}\n", + "qmldir": "module Example\nMain 1.0 Main.qml\n", + "helpers.js": "function helper() { return 1; }\n", + } + source.write_text(content[changed_name], encoding="utf-8") + called = [] + real = ex.extract + def record(paths, **kwargs): + called.extend(p.name for p in paths) + return real(paths, **kwargs) + monkeypatch.setattr(ex, "extract", record) + assert _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False) + assert {"Main.qml", "keep.py"} <= set(called) + + +def test_named_qml_metadata_watch_event_routes_to_local_rebuild(tmp_path): + """qmldir is extensionless metadata, not a semantic/LLM document.""" + source = tmp_path / "qmldir" + source.write_text("module Example\n", encoding="utf-8") + assert _batch_triggers_rebuild([source]) + assert not _batch_needs_llm_flag([source]) diff --git a/tests/test_qml_wheel_artifact.py b/tests/test_qml_wheel_artifact.py new file mode 100644 index 0000000000..f981db86e2 --- /dev/null +++ b/tests/test_qml_wheel_artifact.py @@ -0,0 +1,74 @@ +"""QML-01 built-artifact contract, complementing clean dependency-install CI. + +Set GRAPHIFY_QML_TEST_WHEEL to a reviewed built wheel. These tests deliberately +use installed host dependencies and force parser absence at the import boundary; +they are not substitutes for isolated full-extra/core-only installation jobs. +""" +from __future__ import annotations + +from email.parser import Parser +import json +import os +from pathlib import Path +import subprocess +import sys +import zipfile + +from packaging.requirements import Requirement +import pytest + +from tests.qml_test_helpers import WHEEL_PROBE, by_kind, write_qml + + +@pytest.fixture(scope="module") +def wheel(): + """Do not rebuild/mutate the shared checkout or silently invent an artifact.""" + value = os.environ.get("GRAPHIFY_QML_TEST_WHEEL") + if not value: + pytest.skip("Supply GRAPHIFY_QML_TEST_WHEEL from the built-wheel acceptance job") + path = Path(value) + assert path.is_file() and path.suffix == ".whl", "Reviewed built wheel unavailable" + return path + + +def test_qml001_ac01_built_wheel_contains_adapter_and_optional_extra_metadata(wheel): + """Shipping metadata selects the pinned optional grammar rather than changing core install.""" + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + assert "graphify/extractors/qml.py" in names + assert "graphify/extractors/qml_ast.py" in names + assert "graphify/extractors/qml_declarations.py" in names + assert "graphify/extractors/qml_facts.py" in names + assert not any(name.startswith("tests/") for name in names) + metadata_name = next(name for name in names if name.endswith(".dist-info/METADATA")) + metadata = Parser().parsestr(archive.read(metadata_name).decode("utf-8")) + assert "qml" in metadata.get_all("Provides-Extra", []) + requirements = [Requirement(value) for value in metadata.get_all("Requires-Dist", [])] + grammar = [requirement for requirement in requirements if requirement.name == "tree-sitter-language-pack"] + assert any(requirement.marker is not None and requirement.marker.evaluate({"extra": "qml"}) and str(requirement.specifier) == "==0.11.0" for requirement in grammar) + assert all(requirement.marker is not None and not requirement.marker.evaluate({"extra": ""}) for requirement in grammar) + + +@pytest.mark.parametrize("mode", ["qml", "core"]) +def test_qml001_ac01_ac03_built_artifact_production_import_and_parser_boundary(wheel, tmp_path, mode): + """A fresh interpreter imports the wheel artifact and exercises its actual adapter.""" + target = tmp_path / "artifact" + target.mkdir() + with zipfile.ZipFile(wheel) as archive: + archive.extractall(target) + corpus = tmp_path / "corpus" + write_qml(corpus) + (corpus / "safe.py").write_text("def safe():\n return 1\n", encoding="utf-8") + cwd = tmp_path / "empty-working-directory" + cwd.mkdir() + child = subprocess.run([sys.executable, "-I", "-c", WHEEL_PROBE, str(target), str(corpus), mode], cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=45) + assert child.returncode == 0, child.stderr + result = json.loads(child.stdout) + assert result["python_ok"] is True + if mode == "qml": + assert not result["qml"].get("error") + assert len(by_kind(result["qml"], "component")) == 1 + assert len(by_kind(result["qml"], "property")) == 6 + else: + assert result["qml"].get("error") and result["qml"]["nodes"] == [] + assert any(diagnostic["code"] == "QML_PARSER_MISSING" for diagnostic in result["qml"]["diagnostics"]) diff --git a/tests/test_qt_access_providers.py b/tests/test_qt_access_providers.py new file mode 100644 index 0000000000..a81ffc1706 --- /dev/null +++ b/tests/test_qt_access_providers.py @@ -0,0 +1,129 @@ +"""QML-017 provider scope, meta-object connections and QQmlProperty production cases.""" +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_analysis_helpers import analysis, sites + +BACKEND = '''class Backend : public QObject { + Q_OBJECT + Q_PROPERTY(int count READ count) +public: + int count() { return 1; } + Q_INVOKABLE void refresh() {} +signals: + void changed(); +private slots: + void close() {} +}; +''' +QML = '''import QtQml +QtObject { property int count: 1; function refresh() {} signal closed() } +''' + + +def test_qml_signals_to_cpp_private_slots_and_cpp_signals_to_qml_functions(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = BACKEND + f'''void use(Backend *backend) {{ QQuickView view; view.setSource(QUrl("{url}")); + auto root = view.rootObject(); + QObject::connect(root, SIGNAL(closed()), backend, SLOT(close())); + QObject::connect(backend, SIGNAL(changed()), root, SLOT(refresh())); +}}''' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML}) + connections = sites(result, "connect") + assert [qt_metadata(node)["status"] for node in connections] == ["resolved", "resolved"] + assert [qt_metadata(node)["bridge_direction"] for node in connections] == ["qml_to_cpp", "cpp_to_qml"] + assert not [edge for edge in result["edges"] if edge.get("context", "").startswith("qt_qml_connect") and edge["relation"] == "calls"] + + +def test_qqmlproperty_read_write_preserves_property_handle(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use() {{ QQuickView view; view.setSource(QUrl("{url}")); auto root = view.rootObject(); + QQmlProperty handle(root, "count"); handle.read(); handle.write(2); }}''' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML}) + accesses = sites(result, "qml_access") + assert len(accesses) == 3 + assert all(qt_metadata(node)["status"] == "resolved" for node in accesses), [qt_metadata(node) for node in accesses] + assert len({qt_metadata(node)["target_id"] for node in accesses}) == 1 + static = source.replace('QQmlProperty handle(root, "count"); handle.read(); handle.write(2);', 'QQmlProperty::read(root, "count"); QQmlProperty::write(root, "count", 2);') + result2 = analysis(tmp_path, {"access.cpp": static, "Main.qml": QML}) + assert all(qt_metadata(node)["status"] == "resolved" for node in sites(result2, "qml_access")) + + +def test_context_provider_is_limited_to_loaded_component(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = BACKEND + f'''void use(Backend *backend) {{ QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("backend", backend); engine.load(QUrl("{url}")); }}''' + qml = 'import QtQml\nQtObject { property int value: backend.count; function run() { backend.refresh() } }' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml, "Other.qml": qml}) + bindings = sites(result, "context_binding") + assert len(bindings) == 1 + accesses = sites(result, "context_access") + assert len(accesses) == 2 + assert {node["source_file"] for node in accesses} == {"Main.qml"} + + +def test_duplicate_context_provider_and_local_shadow_do_not_choose(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = BACKEND + f'''void use(Backend *backend, Backend *other) {{ QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("backend", backend); + engine.rootContext()->setContextProperty("backend", other); engine.load(QUrl("{url}")); }}''' + qml = 'import QtQml\nQtObject { property int value: backend.count }' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml}) + assert not sites(result, "context_access") + single_provider = source.replace('engine.rootContext()->setContextProperty("backend", other);', '') + qml2 = 'import QtQml\nQtObject { property var backend; property int value: backend.count; function run(backend) { backend.refresh() } }' + result2 = analysis(tmp_path, {"access.cpp": single_provider, "Main.qml": qml2}) + assert not sites(result2, "context_access") + + +def test_initial_property_provider_requires_declared_qml_property(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = BACKEND + f'''void use(Backend *backend) {{ QQuickView view; + view.setInitialProperties({{{{"backend", backend}}}}); view.setSource(QUrl("{url}")); }}''' + qml = 'import QtQml\nQtObject { property var backend; property int value: backend.count }' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml}) + assert len(sites(result, "context_binding")) == 1 + assert len(sites(result, "context_access")) == 1 + result2 = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml.replace('property var backend;', '')}) + assert not sites(result2, "context_binding") + + +def test_component_create_and_initial_properties_bind_accepted_component(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = BACKEND + f'''void use(Backend *backend) {{ QQmlEngine engine; + QQmlComponent component(&engine, QUrl("{url}")); + QObject *object = component.createWithInitialProperties({{{{"backend", backend}}}}); + object->setProperty("value", 2); }}''' + qml = 'import QtQml\nQtObject { property var backend; property int value: backend.count }' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml}) + assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved" + assert qt_metadata(sites(result, "qml_root")[0])["status"] == "resolved" + assert qt_metadata(sites(result, "qml_access")[0])["status"] == "resolved" + assert len(sites(result, "context_access")) == 1 + + +def test_context_object_and_conditional_or_dynamic_providers(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = BACKEND + f'''void use(Backend *backend, bool enabled) {{ QQmlApplicationEngine engine; + engine.rootContext()->setContextObject(backend); engine.load(QUrl("{url}")); }}''' + qml = 'import QtQml\nQtObject { property int value: count }' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml}) + assert len(sites(result, "context_access")) == 1 + conditional = source.replace('engine.rootContext()->setContextObject(backend);', 'if (enabled) engine.rootContext()->setContextObject(backend);') + result2 = analysis(tmp_path, {"access.cpp": conditional, "Main.qml": qml}) + assert not sites(result2, "context_binding") + assert qt_metadata(sites(result2, "context_exposure")[0])["status"] == "dynamic" + dynamic = BACKEND + f'''void use(QVariantMap properties) {{ QQuickView view; + view.setInitialProperties(properties); view.setSource(QUrl("{url}")); }}''' + result3 = analysis(tmp_path, {"access.cpp": dynamic, "Main.qml": qml}) + assert not sites(result3, "context_binding") + assert qt_metadata(sites(result3, "initial_properties")[0])["reason"] == "computed_initial_properties" + + +def test_typed_qml_signal_and_untyped_function_use_explicit_meta_signatures(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = BACKEND.replace('void changed();', 'void changed(QVariant value);').replace('void close() {}', 'void close(const QString &message) {}') + f'''void use(Backend *backend) {{ QQuickView view; + view.setSource(QUrl("{url}")); auto root = view.rootObject(); + QObject::connect(root, SIGNAL(message(QString)), backend, SLOT(close(QString))); + QObject::connect(backend, SIGNAL(changed(QVariant)), root, SLOT(receive(QVariant))); }}''' + qml = 'import QtQml\nQtObject { signal message(msg: string); function receive(value) {} }' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml}) + assert all(qt_metadata(node)["status"] == "resolved" for node in sites(result, "connect")), [qt_metadata(node) for node in sites(result, "connect")] diff --git a/tests/test_qt_affected_consumers.py b/tests/test_qt_affected_consumers.py new file mode 100644 index 0000000000..737e3487ae --- /dev/null +++ b/tests/test_qt_affected_consumers.py @@ -0,0 +1,49 @@ +"""QML-013-AC02 changed members reach source owners without propagation calls.""" +import pytest + +from graphify.affected import affected_nodes, load_graph +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_affected import owned_ancestors +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_signals_slots import SOURCE + + +@pytest.mark.parametrize("directed", [False, True]) +def test_property_change_reports_binding_and_owning_component(tmp_path, directed): + result = analysis(tmp_path, {"Main.qml": 'import QtQml\nQtObject { property int input: 1; property int output: input }'}) + graph = build_from_json(result, directed=directed, root=tmp_path) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path), force=True) + loaded = load_graph(path) + prop = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "property" and qml_metadata(node).get("raw_name") == "input") + component = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "component") + dependent = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "property" and qml_metadata(node).get("raw_name") == "output") + hits = {hit.node_id for hit in affected_nodes(loaded, prop["id"], depth=2)} + assert dependent["id"] in hits and component["id"] in hits + + +def test_native_signal_changes_report_connection_owner_with_references_filter(tmp_path): + result = analysis(tmp_path, {"events.cpp": SOURCE}) + graph = build_from_json(result, directed=True, root=tmp_path) + signal = qt_metadata(sites(result, "connect")[0])["signal_target_id"] + connection = sites(result, "connect")[0] + hits = {hit.node_id for hit in affected_nodes(graph, signal, relations=["references"], depth=2)} + assert connection["id"] in hits and qt_metadata(connection)["owner_id"] in hits + assert not {connection["id"]} & {hit.node_id for hit in affected_nodes(graph, signal, relations=["calls"], depth=2)} + + +def test_future_metadata_or_foreign_file_ownership_is_not_dependency_evidence(tmp_path): + """Only current source-owned contains links may promote a dependency site.""" + result = analysis(tmp_path, {"Main.qml": 'import QtQml\nQtObject { property int input: 1; property int output: input }'}) + graph = build_from_json(result, directed=True, root=tmp_path) + read = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "read") + ancestors = list(owned_ancestors(graph, read["id"])) + assert ancestors and len(list(owned_ancestors(graph, read["id"], limit=2))) == 1 + graph.nodes[read["id"]]["metadata"]["qml"]["contract_version"] = 2 + assert not list(owned_ancestors(graph, read["id"])) + graph.nodes[read["id"]]["metadata"]["qml"]["contract_version"] = 1 + graph.nodes[ancestors[0][0]]["source_file"] = "Other.qml" + assert not list(owned_ancestors(graph, read["id"])) diff --git a/tests/test_qt_affected_definitions.py b/tests/test_qt_affected_definitions.py new file mode 100644 index 0000000000..8e36510bba --- /dev/null +++ b/tests/test_qt_affected_definitions.py @@ -0,0 +1,56 @@ +"""QML-016-AC04 canonical C++ header/implementation ownership in affected results.""" +import pytest + +from graphify.affected import affected_nodes +from graphify.build import build_from_json +from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt +from tests.qt_analysis_helpers import analysis, sites + +HEADER = '''class Emitter: public QObject { Q_OBJECT +public: void wire(Emitter *other); void accept(); +signals: void changed(); +};''' +SOURCE = '''void Emitter::wire(Emitter *other) { + QObject::connect(other, &Emitter::changed, this, &Emitter::accept); +} +void Emitter::accept() {} +''' + + +def fixture(tmp_path): + result = analysis(tmp_path, {"emitter.h": HEADER, "emitter.cpp": SOURCE}) + graph = build_from_json(result, root=tmp_path, directed=True) + site = sites(result, "connect")[0] + metadata = qt_metadata(site) + assert metadata["status"] == "resolved" + owner = graph.nodes[metadata["owner_id"]] + assert owner["source_file"] == "emitter.h" and owner["definition_file"] == "emitter.cpp" + return graph, site, metadata + + +def test_qml016_ac04_signal_change_reports_canonical_out_of_line_function_owner(tmp_path): + """A real implementation site promotes its canonical header-owned method.""" + graph, site, metadata = fixture(tmp_path) + hits = affected_nodes(graph, metadata["signal_target_id"], relations=["references"], depth=2) + by_id = {hit.node_id: hit for hit in hits} + assert site["id"] in by_id and metadata["owner_id"] in by_id + assert by_id[metadata["owner_id"]].via_file == "emitter.cpp" + assert by_id[metadata["owner_id"]].via_location == site["source_location"] + assert metadata["owner_id"] not in {hit.node_id for hit in affected_nodes(graph, metadata["signal_target_id"], relations=["calls"], depth=2)} + + +@pytest.mark.parametrize("invalid", ["definition", "callable", "owner", "span"]) +def test_qml016_ac04_foreign_or_unproven_definition_cannot_promote_owner(tmp_path, invalid): + """Borrowed source fields alone cannot authorize unrelated function ownership.""" + graph, site, metadata = fixture(tmp_path) + owner = graph.nodes[metadata["owner_id"]] + if invalid == "definition": + owner["definition_file"] = "other.cpp" + elif invalid == "callable": + owner.pop("_callable") + elif invalid == "owner": + update_qt(graph.nodes[site["id"]], owner_id="foreign_owner") + else: + update_qt(graph.nodes[site["id"]], span={**metadata["span"], "end_byte": metadata["span"]["end_byte"] + 1}) + hits = {hit.node_id for hit in affected_nodes(graph, metadata["signal_target_id"], relations=["references"], depth=2)} + assert site["id"] in hits and metadata["owner_id"] not in hits diff --git a/tests/test_qt_affected_direction.py b/tests/test_qt_affected_direction.py new file mode 100644 index 0000000000..5507f51b84 --- /dev/null +++ b/tests/test_qt_affected_direction.py @@ -0,0 +1,246 @@ +"""INC-QML-30: affected traversal preserves accepted serialized edge direction.""" +from __future__ import annotations + +import json + +import networkx as nx +import pytest + +from graphify.affected import affected_nodes +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.paths import load_node_link_graph +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qml_facts import qml_metadata +from graphify.qt_affected import owned_ancestors +from graphify.serve import _query_graph_text +import graphify.__main__ as mainmod +from tests.qt_adoption_fixture import sources +from tests.qt_analysis_helpers import analysis, sites + + +@pytest.mark.parametrize("expression", ["factory->makeBackend()", "backend"]) +@pytest.mark.parametrize("directed", [False, True]) +@pytest.mark.parametrize("reverse_insertion", [False, True]) +def test_native_provider_affected_uses_accepted_serialized_direction(tmp_path, monkeypatch, capsys, + expression, directed, reverse_insertion): + """The real source access remains an incoming dependency of its native endpoint after JSON reload.""" + result = analysis(tmp_path, sources(tmp_path, expression)) + if reverse_insertion: + result["nodes"] = list(reversed(result["nodes"])) + result["edges"] = list(reversed(result["edges"])) + graph = build_from_json(result, root=tmp_path, directed=directed) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path)) + reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + access = next(node for node in sites(result, "context_access") + if qt_metadata(node)["endpoint_proof"]["kind"] == "function") + target = qt_metadata(access)["endpoint_proof"]["canonical_target_id"] + assert reloaded.has_edge(access["id"], target) + if not directed: + metadata = reloaded.edges[access["id"], target] + assert metadata["_src"] == access["id"] and metadata["_tgt"] == target + hits = {hit.node_id: hit for hit in affected_nodes(reloaded, target, depth=3)} + assert access["id"] in hits + assert hits[access["id"]].via_file == "Main.qml" + assert hits[access["id"]].via_location == access["source_location"] + assert "refresh" in _query_graph_text(reloaded, "refresh", token_budget=1500) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda *_: None) + monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "affected", target, "--graph", str(path), "--depth", "3"]) + mainmod.main() + out = capsys.readouterr().out + assert reloaded.nodes[access["id"]]["label"] in out + assert f"Main.qml:{access['source_location']}" in out + + +@pytest.mark.parametrize("directed", [False, True]) +@pytest.mark.parametrize("reverse_insertion", [False, True]) +def test_logical_member_seed_finds_caller_without_reporting_seeded_method(directed, reverse_insertion): + """Physical iteration order cannot reverse either the root's member seed or incoming caller.""" + graph = nx.DiGraph() if directed else nx.Graph() + order = ["class", "method", "caller"] + graph.add_nodes_from(reversed(order) if reverse_insertion else order) + graph.add_edge("class", "method", relation="method", _src="class", _tgt="method") + graph.add_edge("caller", "method", relation="calls", _src="caller", _tgt="method", + source_file="consumer.py", source_location="L17") + hits = affected_nodes(graph, "class", depth=1) + assert [(hit.node_id, hit.depth, hit.via_relation, hit.via_file, hit.via_location) for hit in hits] == [ + ("caller", 1, "calls", "consumer.py", "L17")] + assert not affected_nodes(graph, "class", relations=["references"], depth=2) + + +@pytest.mark.parametrize("directed", [False, True]) +@pytest.mark.parametrize("direction", [ + {"_src": "caller"}, {"_tgt": "target"}, {"_src": "foreign", "_tgt": "target"}, + {"_src": "caller", "_tgt": "foreign"}, {"_src": None, "_tgt": "target"}, + {"_src": ["caller"], "_tgt": "target"}, {"_src": "caller", "_tgt": {"id": "target"}}, +]) +def test_corrupt_direction_cannot_fall_back_to_physical_incoming(directed, direction): + """Partial, foreign or malformed transport rejects its edge rather than becoming legacy direction.""" + graph = nx.DiGraph() if directed else nx.Graph() + graph.add_nodes_from(["caller", "target", "foreign"]) + graph.add_edge("caller", "target", relation="references", **direction) + assert not affected_nodes(graph, "target", depth=1) + + +def test_directed_direction_must_agree_with_physical_endpoints(): + """A directed edge cannot claim the opposite orientation and silently change its dependency.""" + graph = nx.DiGraph() + graph.add_edge("caller", "target", relation="calls", _src="target", _tgt="caller") + assert not affected_nodes(graph, "target", depth=1) + + +@pytest.mark.parametrize("directed", [False, True]) +@pytest.mark.parametrize("reverse_insertion", [False, True]) +def test_legacy_edges_without_direction_keep_existing_traversal(directed, reverse_insertion): + """An unmarked generic graph retains its historical raw endpoint interpretation.""" + graph = nx.DiGraph() if directed else nx.Graph() + order = ["caller", "target"] + graph.add_nodes_from(reversed(order) if reverse_insertion else order) + graph.add_edge("caller", "target", relation="calls") + expected_source, expected_target = ("target", "caller") if reverse_insertion and not directed else ("caller", "target") + assert [hit.node_id for hit in affected_nodes(graph, expected_target, depth=1)] == [expected_source] + assert not affected_nodes(graph, expected_source, depth=1) + + +@pytest.mark.parametrize("directed", [False, True]) +@pytest.mark.parametrize("role", ["method", "contains"]) +def test_partial_member_direction_cannot_seed_reverse_traversal(directed, role): + """A root member edge with one missing marker cannot silently seed a caller's dependency.""" + graph = nx.DiGraph() if directed else nx.Graph() + graph.add_edge("class", "member", relation=role, _src="class") + graph.add_edge("caller", "member", relation="calls", _src="caller", _tgt="member") + assert not affected_nodes(graph, "class", depth=2) + + +@pytest.mark.parametrize("directed,corruption", [(False, "partial_source"), (False, "partial_target"), + (False, "foreign"), (True, "partial_source"), (True, "partial_target"), (True, "foreign"), + (True, "reversed_directed")]) +def test_qt_owner_promotion_rejects_partial_or_foreign_direction(tmp_path, directed, corruption): + """An accepted real QML read cannot promote a forged owner from damaged contains direction.""" + result = analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"}) + graph = build_from_json(result, root=tmp_path, directed=directed) + access = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "read") + owners = list(owned_ancestors(graph, access["id"])) + assert owners + owner, data = owners[0] + target = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "property" + and qml_metadata(node).get("raw_name") == "input") + assert owner in {hit.node_id for hit in affected_nodes(graph, target, depth=1)} + assert data["_src"] == owner and data["_tgt"] == access["id"] + if corruption == "partial_source": + data.pop("_src") + elif corruption == "partial_target": + data.pop("_tgt") + elif corruption == "foreign": + graph.add_node("foreign", label="Foreign", source_file=access["source_file"]) + data["_src"] = "foreign" + else: + data["_src"], data["_tgt"] = data["_tgt"], data["_src"] + assert not list(owned_ancestors(graph, access["id"])) + hits = {hit.node_id for hit in affected_nodes(graph, target, depth=1)} + assert access["id"] in hits and owner not in hits and "foreign" not in hits + + +@pytest.mark.parametrize("directed", [False, True]) +def test_logical_direction_depth_cycles_and_repeated_query_preserve_graph(directed): + """Bounded reverse traversal ignores outgoing dependencies and terminates cycles without changing data.""" + graph = nx.DiGraph() if directed else nx.Graph() + graph.add_nodes_from(["seed", "second", "first"]) + for source, target in (("first", "seed"), ("second", "first"), ("seed", "second")): + graph.add_edge(source, target, relation="references", _src=source, _tgt=target) + before = nx.node_link_data(graph, edges="links") + assert not affected_nodes(graph, "seed", depth=0) + assert [(hit.node_id, hit.depth) for hit in affected_nodes(graph, "seed", depth=1)] == [("first", 1)] + expected = [("first", 1), ("second", 2)] + assert [(hit.node_id, hit.depth) for hit in affected_nodes(graph, "seed", depth=8)] == expected + assert [(hit.node_id, hit.depth) for hit in affected_nodes(graph, "seed", depth=8)] == expected + assert nx.node_link_data(graph, edges="links") == before + + +@pytest.mark.parametrize("directed", [False, True]) +def test_parallel_edges_keep_valid_filtered_mechanism_after_invalid_direction(directed): + """A rejected parallel edge cannot hide another accepted mechanism between the same endpoints.""" + graph = nx.MultiDiGraph() if directed else nx.MultiGraph() + graph.add_nodes_from(["target", "caller"]) + graph.add_edge("caller", "target", relation="calls", _src="foreign", _tgt="target") + graph.add_edge("caller", "target", relation="references", _src="caller", _tgt="target", + source_file="client.py", source_location="L27") + hits = affected_nodes(graph, "target", relations=["references"], depth=1) + assert [(hit.node_id, hit.via_relation, hit.via_location) for hit in hits] == [("caller", "references", "L27")] + assert not affected_nodes(graph, "target", relations=["calls"], depth=1) + + +@pytest.mark.parametrize("directed", [False, True]) +def test_boolean_marker_cannot_coerce_integer_endpoint_identity(directed): + """Serialized bool is not the accepted integer endpoint even though Python equality equates them.""" + graph = nx.DiGraph() if directed else nx.Graph() + graph.add_edge(1, "target", relation="references", _src=True, _tgt="target") + assert not affected_nodes(graph, "target", depth=1) + + +@pytest.mark.parametrize("markers", [{"_src": "source"}, {"_tgt": "target"}, + {"_src": "foreign", "_tgt": "target"}, {"_src": None, "_tgt": "target"}, + {"_src": ["source"], "_tgt": "target"}]) +def test_typed_reload_preserves_corrupt_dependency_direction_for_rejection(tmp_path, markers): + """Actual JSON reload must not replace damaged explicit markers with a trusted serialized pair.""" + result = analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"}) + graph = build_from_json(result, root=tmp_path) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path)) + source = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "read") + target = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "property" + and qml_metadata(node).get("raw_name") == "input") + original = json.loads(path.read_text(encoding="utf-8")) + # The real writer omits its internal markers. Untouched serialized producer + # endpoints remain the normal positive control before damaging this edge. + assert source in {hit.node_id for hit in affected_nodes(load_node_link_graph(path), target, depth=1)} + link = next(link for link in original["links"] if link["source"] == source and link["target"] == target) + link.update({key: source if value == "source" else target if value == "target" else value + for key, value in markers.items()}) + path.write_text(json.dumps(original), encoding="utf-8") + reloaded = load_node_link_graph(path) + assert source not in {hit.node_id for hit in affected_nodes(reloaded, target, depth=1)} + + +@pytest.mark.parametrize("markers", [{"_src": "source"}, {"_tgt": "target"}, + {"_src": "foreign", "_tgt": "target"}]) +def test_typed_reload_preserves_corrupt_owner_direction_for_rejection(tmp_path, markers): + """A persisted real contains edge cannot acquire source ownership by overwriting partial/foreign markers.""" + result = analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"}) + graph = build_from_json(result, root=tmp_path) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path)) + child = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "read") + owner = list(owned_ancestors(graph, child))[0][0] + original = json.loads(path.read_text(encoding="utf-8")) + assert list(owned_ancestors(load_node_link_graph(path), child)) + link = next(link for link in original["links"] if link["source"] == owner and link["target"] == child) + link.update({key: owner if value == "source" else child if value == "target" else value + for key, value in markers.items()}) + # Give the forged endpoint an otherwise plausible same-file node, making + # endpoint-pair validation the actual protection rather than missing-node luck. + original["nodes"].append({"id": "foreign", "label": "Foreign", "source_file": "Main.qml"}) + path.write_text(json.dumps(original), encoding="utf-8") + assert not list(owned_ancestors(load_node_link_graph(path), child)) + + +@pytest.mark.parametrize("reversed_pair", [False, True]) +def test_typed_reload_preserves_valid_complete_explicit_pair(tmp_path, reversed_pair): + """A valid complete pair retains its declared orientation instead of being silently replaced on reload.""" + result = analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"}) + graph = build_from_json(result, root=tmp_path) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path)) + source = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "read") + target = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "property" + and qml_metadata(node).get("raw_name") == "input") + data = json.loads(path.read_text(encoding="utf-8")) + link = next(link for link in data["links"] if link["source"] == source and link["target"] == target) + expected = (target, source) if reversed_pair else (source, target) + link["_src"], link["_tgt"] = expected + path.write_text(json.dumps(data), encoding="utf-8") + reloaded = load_node_link_graph(path) + attributes = reloaded.edges[source, target] + assert (attributes["_src"], attributes["_tgt"]) == expected + assert (source in {hit.node_id for hit in affected_nodes(reloaded, target, depth=1)}) is not reversed_pair diff --git a/tests/test_qt_affected_direction_updates.py b/tests/test_qt_affected_direction_updates.py new file mode 100644 index 0000000000..fa4c134ae1 --- /dev/null +++ b/tests/test_qt_affected_direction_updates.py @@ -0,0 +1,66 @@ +"""INC-QML-30: affected direction across real cold/manual/watch publication.""" +from __future__ import annotations + +import pytest + +from graphify.affected import affected_nodes +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_adoption_fixture import sources +from tests.qt_analysis_helpers import analysis +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated + + +def native_dependency(graph): + """The accepted provider function, occurrence and call-site location form one consumer contract.""" + candidates = [(identity, data) for identity, data in graph.nodes(data=True) + if qt_metadata(data).get("kind") == "context_access" + and qt_metadata(data).get("endpoint_proof", {}).get("kind") == "function"] + assert len(candidates) == 1 + identity, data = candidates[0] + target = qt_metadata(data)["endpoint_proof"]["canonical_target_id"] + hits = {hit.node_id: hit for hit in affected_nodes(graph, target, relations=["calls"], depth=1)} + assert identity in hits and hits[identity].depth == 1 + assert (hits[identity].via_file, hits[identity].via_location) == ("Main.qml", data["source_location"]) + return identity, target + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("expression", ["factory->makeBackend()", "backend"]) +def test_native_affected_direction_cold_warm_update_removal_recovery_and_repeat(tmp_path, monkeypatch, + operation, expression): + """A real QML edit retires its incoming access; repair restores cold-equivalent affected results.""" + corpus = sources(tmp_path, expression) + corpus["keep.py"] = "def helper(): return 7\n\ndef retained(): return helper()\n" + analysis(tmp_path, corpus) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + cold = clean(tmp_path, tmp_path / ".cold-cache") + assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold) + initial = run(tmp_path, monkeypatch, operation) + assert normalized(initial) == normalized(cold) + access, target = native_dependency(initial) + assert native_dependency(cold) == (access, target) + kept = unrelated(initial) + + # Keep the native declaration while removing this actual QML member use. + # The consumer must not report an access whose old source occurrence retired. + qml = tmp_path / "Main.qml" + original = qml.read_bytes() + qml.write_bytes(original.replace(b"backend.service.refresh()", b"backend.service.missing()")) + removed = run(tmp_path, monkeypatch, operation, [qml]) + assert target in removed and access not in removed + assert not affected_nodes(removed, target, relations=["calls"], depth=1) + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache")) + assert unrelated(removed) == kept + + # Recovery and a no-change update exercise the durable production cohort; + # querying affected nodes itself must never rewrite the published graph. + qml.write_bytes(original) + repaired = run(tmp_path, monkeypatch, operation, [qml]) + assert native_dependency(repaired) == (access, target) + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired-cache")) + output = tmp_path / "graphify-out/graph.json" + before = output.read_bytes() + repeated = run(tmp_path, monkeypatch, operation, []) + assert native_dependency(repeated) == (access, target) + assert output.read_bytes() == before and unrelated(repeated) == kept diff --git a/tests/test_qt_alias_policy_upgrade.py b/tests/test_qt_alias_policy_upgrade.py new file mode 100644 index 0000000000..3b42213597 --- /dev/null +++ b/tests/test_qt_alias_policy_upgrade.py @@ -0,0 +1,85 @@ +"""REQ-QML-020: unchanged native products refresh after alias policy upgrades.""" +from __future__ import annotations + +import pytest + +import graphify.extract as extraction +import graphify.qt_analysis_state as state +import graphify.qt_incremental as policy +from graphify.watch import _rebuild_code +from tests.test_qt_final_incremental_parity import clean, normalized, project, run +from tests.test_qt_product_publication import no_scratch, snapshot + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("corpus", ["mixed", "cpp_only"]) +def test_req_qml020_ac03_policy18_no_change_upgrade_retains_repairs_and_repeats( + tmp_path, monkeypatch, capsys, operation, corpus): + """A prior valid cohort refreshes without edits; failed stamp publication retains it.""" + project(tmp_path) + if corpus == "cpp_only": + # An ordinary native-only corpus also owns a Qt analysis stamp. Remove + # QML/build/resource inputs before admission and use no Qt annotations. + for name in ("Main.qml", "CMakeLists.txt", "resources.qrc", "backend.h"): + (tmp_path / name).unlink() + (tmp_path / "loader.cpp").write_bytes(b"int helper(){return 7;} int caller(){return helper();}\n") + required_inputs = ({"loader.cpp"} if corpus == "cpp_only" else + {"Main.qml", "backend.h", "loader.cpp", "CMakeLists.txt", "resources.qrc"}) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + # Produce the prior stamp through the real owner rather than constructing a + # counterfeit manifest. The source bytes and accepted graph stay unchanged. + with monkeypatch.context() as prior: + prior.setattr(policy, "QT_POLICY_VERSION", 18) + initial = run(tmp_path, prior, operation) + output = tmp_path / "graphify-out" + before = snapshot(output) + prior_fingerprint = state.read_qt_fingerprint(output) + source_bytes = {path.name: path.read_bytes() for path in tmp_path.iterdir() if path.is_file()} + observed, real_extract = [], extraction.extract + + def record_inputs(paths, *args, **kwargs): + paths = list(paths) + observed.append({path.name for path in paths}) + return real_extract(paths, *args, **kwargs) + + monkeypatch.setattr(extraction, "extract", record_inputs) + committed, real_commit = [], state.commit_qt_analysis + + def stage_then_fail(*args, **kwargs): + # Candidate stamp serialization really completes. Failure before cohort + # replacement cannot authorize any graph/manifest/root/stamp advancement. + real_commit(*args, **kwargs) + committed.append(True) + raise OSError("public policy upgrade publication failure") + + with monkeypatch.context() as failed: + failed.setattr(state, "commit_qt_analysis", stage_then_fail) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, failed, operation, []) + assert rejected.value.code == 1 + else: + assert not _rebuild_code(tmp_path, changed_paths=[], no_cluster=True) + assert committed == [True] + assert snapshot(output) == before + assert state.read_qt_fingerprint(output) == prior_fingerprint + assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out + assert observed and required_inputs <= set.union(*observed) + no_scratch(output) + + observed.clear() + repaired = run(tmp_path, monkeypatch, operation, []) + assert normalized(repaired) == normalized(initial) + assert state.read_qt_fingerprint(output) != prior_fingerprint + assert observed and required_inputs <= set.union(*observed) + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".clean-cache")) + accepted = snapshot(output) + observed.clear() + repeated = run(tmp_path, monkeypatch, operation, []) + assert normalized(repeated) == normalized(repaired) + assert snapshot(output) == accepted + if operation == "watch": + assert not any(observed) + assert source_bytes == {path.name: path.read_bytes() for path in tmp_path.iterdir() if path.is_file()} + no_scratch(output) diff --git a/tests/test_qt_analysis_state.py b/tests/test_qt_analysis_state.py new file mode 100644 index 0000000000..89fe486433 --- /dev/null +++ b/tests/test_qt_analysis_state.py @@ -0,0 +1,134 @@ +"""QML-011 config/parser-contract state; incomplete stages cannot advance a stamp.""" +import json + +import pytest + +from graphify.qt_analysis_state import ( + QT_STATE_FILE, commit_qt_analysis, inspect_qt_analysis, qt_configuration_changed, + read_qt_fingerprint, save_qt_fingerprint, +) +from graphify.qt_incremental import qt_analysis_fingerprint + + +def fingerprint(**changes): + return qt_analysis_fingerprint(**{"parser_version": "qmljs-test-1", "fact_version": 1, **changes}) + + +def test_successful_configuration_checkpoint_and_contract_change_require_refresh(tmp_path): + original = fingerprint() + assert qt_configuration_changed(tmp_path, original) + assert not tmp_path.joinpath(QT_STATE_FILE).exists() + save_qt_fingerprint(tmp_path, original) + assert read_qt_fingerprint(tmp_path) == original + assert not qt_configuration_changed(tmp_path, original) + for changed in (fingerprint(parser_version="qmljs-test-2"), fingerprint(fact_version=2), + fingerprint(import_roots=["imports", "."]), fingerprint(ignore_patterns=["generated/"])): + assert qt_configuration_changed(tmp_path, changed) + assert read_qt_fingerprint(tmp_path) == original + payload = json.loads(tmp_path.joinpath(QT_STATE_FILE).read_text()) + assert set(payload) == {"schema", "fingerprint", "has_qt"} + + +@pytest.mark.parametrize("raw", [ + b"invalid", b"\xff", b"[]", b'{"schema":2,"fingerprint":"' + b"a" * 64 + b'"}', + b'{"schema":true,"fingerprint":"' + b"a" * 64 + b'"}', + b'{"schema":1,"fingerprint":"bad"}', b"x" * 1025, +]) +def test_corrupt_or_incompatible_state_cannot_validate_cached_resolution(tmp_path, raw): + path = tmp_path / QT_STATE_FILE + path.write_bytes(raw) + assert read_qt_fingerprint(tmp_path) is None + assert qt_configuration_changed(tmp_path, fingerprint()) + assert path.read_bytes() == raw + + +def test_invalid_checkpoint_and_failed_publication_do_not_replace_last_valid_state(tmp_path, monkeypatch): + save_qt_fingerprint(tmp_path, fingerprint()) + original = (tmp_path / QT_STATE_FILE).read_bytes() + with pytest.raises(ValueError, match="QT_CONFIG"): + save_qt_fingerprint(tmp_path, "invalid") + def unavailable(*_args, **_kwargs): + raise OSError("isolated persistence failure") + monkeypatch.setattr("graphify.qt_analysis_state.write_text_atomic", unavailable) + with pytest.raises(OSError): + save_qt_fingerprint(tmp_path, fingerprint(fact_version=2)) + assert (tmp_path / QT_STATE_FILE).read_bytes() == original + + +def test_inspection_import_root_order_and_known_input_inventory(tmp_path, monkeypatch): + root, out = tmp_path / "root", tmp_path / "out" + root.mkdir() + path = root / "Main.qml" + path.write_text("Item {}") + state = inspect_qt_analysis(root, out, [path]) + assert state.changed and state.has_qt and state.import_roots == (".",) + assert not out.exists() + commit_qt_analysis(out, state) + assert not inspect_qt_analysis(root, out, [path]).changed + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["imports", "."]') + imported = inspect_qt_analysis(root, out, [path]) + assert imported.changed and imported.import_roots == ("imports", ".") + assert not (root / "imports").exists() + commit_qt_analysis(out, imported) + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '[".", "imports"]') + assert inspect_qt_analysis(root, out, [path]).changed + removed = inspect_qt_analysis(root, out, []) + assert removed.changed and removed.has_qt and not removed.current_has_qt + commit_qt_analysis(out, removed) + assert not inspect_qt_analysis(root, out, []).has_qt + + +def test_package_fact_policy_and_ignore_configuration_invalidate_unchanged_qml(tmp_path, monkeypatch): + from graphify.qt_incremental import QT_POLICY_VERSION + + path = tmp_path / "nested/Main.qml" + path.parent.mkdir() + path.write_text("Item {}") + out = tmp_path / "out" + monkeypatch.setattr("graphify.qt_analysis_state._package_versions", lambda: "test-pack-1") + state = inspect_qt_analysis(tmp_path, out, [path]) + commit_qt_analysis(out, state) + monkeypatch.setattr("graphify.qt_analysis_state._package_versions", lambda: "test-pack-2") + assert inspect_qt_analysis(tmp_path, out, [path]).changed + monkeypatch.setattr("graphify.qt_analysis_state._package_versions", lambda: "test-pack-1") + monkeypatch.setattr("graphify.qt_analysis_state.CONTRACT_VERSION", 2) + assert inspect_qt_analysis(tmp_path, out, [path]).changed + monkeypatch.setattr("graphify.qt_analysis_state.CONTRACT_VERSION", 1) + monkeypatch.setattr("graphify.qt_incremental.QT_POLICY_VERSION", QT_POLICY_VERSION + 1) + assert inspect_qt_analysis(tmp_path, out, [path]).changed + monkeypatch.setattr("graphify.qt_incremental.QT_POLICY_VERSION", QT_POLICY_VERSION) + ignore = path.parent / ".graphifyignore" + ignore.write_bytes(b"ignored.qml\n") + assert inspect_qt_analysis(tmp_path, out, [path]).changed + ignore.unlink() + (tmp_path / ".gitignore").write_bytes(b"generated/\n") + assert inspect_qt_analysis(tmp_path, out, [path]).changed + assert not inspect_qt_analysis(tmp_path, out, [path], gitignore=False).fingerprint == state.fingerprint + assert inspect_qt_analysis(tmp_path, out, [path], excludes=["generated/"]).changed + + +@pytest.mark.parametrize("raw", ["invalid", "{}", "[1]", '["../outside"]', '["/outside"]', + '["C:/outside"]', '[""]', '["a/../../outside"]', + "[" + ",".join('"a"' for _ in range(257)) + "]", "x" * 65537], + ids=["invalid-json", "object", "number", "parent", "absolute", "drive", + "empty", "traversal", "count-limit", "byte-limit"]) +def test_malformed_or_outside_import_config_rejects_before_stamp_write(tmp_path, monkeypatch, raw): + if len(raw) > 32767: + monkeypatch.setattr("graphify.qt_analysis_state.os.environ", {"GRAPHIFY_QML_IMPORT_ROOTS": raw}) + else: + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", raw) + with pytest.raises(ValueError, match="QT_CONFIG"): + inspect_qt_analysis(tmp_path, tmp_path / "out", []) + assert not (tmp_path / "out").exists() + + +def test_inspection_bounded_ignores_and_outside_inputs_do_not_read_host_files(tmp_path): + root = tmp_path / "root" + root.mkdir() + (root / ".graphifyignore").write_bytes(b"x" * 1_048_577) + with pytest.raises(ValueError, match="QT_CONFIG_LIMIT"): + inspect_qt_analysis(root, root / "out", []) + with pytest.raises(ValueError, match="QT_CONFIG"): + inspect_qt_analysis(root, root / "out", [tmp_path / "host.qml"]) + with pytest.raises(ValueError, match="QT_CONFIG"): + inspect_qt_analysis(root, root / "out", [root]) diff --git a/tests/test_qt_bridge_mechanism_integrity.py b/tests/test_qt_bridge_mechanism_integrity.py new file mode 100644 index 0000000000..39c2fed420 --- /dev/null +++ b/tests/test_qt_bridge_mechanism_integrity.py @@ -0,0 +1,188 @@ +"""INC-QML-36: accepted source mechanisms survive every bridge consumer boundary.""" +from __future__ import annotations + +import copy +from pathlib import Path + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qml_facts import encode_metadata, qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt +from graphify.paths import load_node_link_graph +from graphify.qt_qml_projection import allows_qt_qml_edge +from tests.qt_adoption_fixture import sources +from tests.qt_analysis_helpers import analysis +from tests.test_qt_access_providers import BACKEND + + +def fixture(root, profile): + """Only ordinary public source fixtures produce the bridge facts under review.""" + if profile == "context": + inputs = sources(root) + elif profile == "native": + native = BACKEND.replace("changed", "ready") + native += 'void registerTypes() { qmlRegisterType("Demo.Tools", 1, 0, "Service"); }' + qml = ('import QtQml\nimport Demo.Tools 1.0\nService { id: service; ' + 'property int displayed: count; property alias mirrored: service.count; ' + 'function run() { refresh(); ready() } onReady: {} }') + inputs = {"backend.cpp": native, "Main.qml": qml} + else: + url = (root / "Main.qml").as_uri() + native = BACKEND + f'''void use(Backend *backend) {{ QQuickView view; + view.setSource(QUrl("{url}")); auto root = view.rootObject(); + root->property("count"); root->setProperty("count", 2); + QMetaObject::invokeMethod(root, "refresh"); QMetaObject::invokeMethod(root, "closed"); + auto child = root->findChild("details"); child->property("count"); + QObject::connect(root, SIGNAL(closed()), backend, SLOT(close())); + QObject::connect(backend, SIGNAL(changed()), root, SLOT(refresh())); }}''' + if profile == "disconnect": + native = native.replace("QObject::connect", "QObject::disconnect") + qml = ('import QtQml\nQtObject { property int count: 1; function refresh() {} signal closed(); ' + 'property QtObject child: QtObject { objectName: "details"; property int count: 2 } }') + inputs = {"access.cpp": native, "Main.qml": qml} + inputs["keep.py"] = "def helper(): return 7\ndef retained(): return helper()\n" + return analysis(root, inputs) + + +def selected(result, context): + nodes = {node["id"]: node for node in result["nodes"]} + return next(edge for edge in result["edges"] if edge.get("context") == context + and (Path(nodes[edge["source"]]["source_file"]).suffix == ".qml") + != (Path(nodes[edge["target"]]["source_file"]).suffix == ".qml")) + + +def accepted(result, edge): + nodes = {node["id"]: node for node in result["nodes"]} + return allows_qt_qml_edge(nodes[edge["source"]], nodes[edge["target"]], edge, + source_id=edge["source"], target_id=edge["target"], nodes=nodes) + + +CASES = [("context", value) for value in ("qt_context_member", "qt_context_subscription")] +CASES += [("native", value) for value in ("qml_import_resolution", "qml_type_resolution", + "qml_binding_read", "qml_alias_target", "qml_js_call", "qml_signal_emit", "qml_signal_subscription")] +CASES += [("reverse", value) for value in ("qt_cpp_qml_load", "qt_cpp_qml_property_read", + "qt_cpp_qml_property_write", "qt_cpp_qml_find_child", "qt_cpp_qml_invoke", + "qt_qml_connect_signal", "qt_qml_connect_receiver")] +CASES += [("disconnect", value) for value in ("qt_qml_disconnect_signal", "qt_qml_disconnect_receiver")] + + +@pytest.mark.parametrize("profile,context", CASES) +@pytest.mark.parametrize("directed", [False, True]) +def test_source_mechanisms_survive_builder_export_and_reload(tmp_path, profile, context, directed): + """Valid source facts retain their observed operation rather than becoming generic calls.""" + result = fixture(tmp_path, profile) + edge = selected(result, context) + assert accepted(result, edge) + graph = build_from_json(result, root=tmp_path, directed=directed) + assert graph.has_edge(edge["source"], edge["target"]) + path = tmp_path / "accepted.json" + assert to_json(graph, {}, str(path), force=True) + restored = load_node_link_graph(path) + source, target = edge["source"], edge["target"] + data = restored.edges[source, target] + assert data["relation"] == edge["relation"] and data["context"] == context + assert allows_qt_qml_edge(restored.nodes[source], restored.nodes[target], data, + source_id=source, target_id=target, nodes=restored.nodes) + assert any(node.get("source_file") == "keep.py" for _, node in restored.nodes(data=True)) + + +@pytest.mark.parametrize("profile,context", CASES) +@pytest.mark.parametrize("corruption", ["relation", "context", "confidence", "inverse", "status", "span"]) +def test_source_mechanism_substitution_is_rejected_before_graph_assembly(tmp_path, profile, context, corruption): + """A changed real producer edge/site cannot retain a valid-looking endpoint proof.""" + result = fixture(tmp_path, profile) + edge = selected(result, context) + # Disconnect projection was previously missing from the consumer's bounded + # whitelist despite being emitted by the ordinary cross-language resolver. + if "disconnect" not in context: + assert accepted(result, edge) + nodes = {node["id"]: node for node in result["nodes"]} + source = nodes[edge["source"]] + if corruption == "relation": + edge["relation"] = "references" if edge["relation"] == "calls" else "calls" + elif corruption == "confidence": + edge["confidence"] = "EXTRACTED" + elif corruption == "context": + edge["context"] = "qt_cpp_qml_invoke" if profile in {"reverse", "disconnect"} else "qml_js_call" + if context == edge["context"]: + edge["context"] = "qt_cpp_qml_property_read" if profile == "reverse" else "qml_binding_read" + elif corruption == "inverse": + edge["source"], edge["target"] = edge["target"], edge["source"] + edge["_src"], edge["_tgt"] = edge["source"], edge["target"] + elif profile == "native": + md = {key: value for key, value in qml_metadata(source).items() if key != "raw_values"} + md.update(status="unavailable") if corruption == "status" else md.update(span={**md["span"], "start_byte": 999999}) + source["metadata"]["qml"] = encode_metadata(md) + else: + md = qt_metadata(source) + update_qt(source, **({"status": "unavailable"} if corruption == "status" + else {"span": {**md["span"], "start_byte": 999999}})) + assert not accepted(result, edge) + graph = build_from_json(result, root=tmp_path) + assert not graph.has_edge(edge["source"], edge["target"]) + assert any(node.get("source_file") == "keep.py" for _, node in graph.nodes(data=True)) + + +@pytest.mark.parametrize("corruption", ["owner_kind", "owner_status", "owner_span", "owner_file", "owner_location", "lexical"]) +def test_context_access_revalidates_original_read_or_call(tmp_path, corruption): + """The derived access repeats source ownership and lexical admission after serialization.""" + result = fixture(tmp_path, "context") + edge = selected(result, "qt_context_member") + nodes = {node["id"]: node for node in result["nodes"]} + source = nodes[edge["source"]] + owner = nodes[qt_metadata(source)["owner_id"]] + md = {key: value for key, value in qml_metadata(owner).items() if key != "raw_values"} + if corruption == "owner_file": + owner["source_file"] = "Other.qml" + elif corruption == "owner_location": + owner["source_location"] = "L999" + else: + fields = {"owner_kind": {"kind": "alias"}, "owner_status": {"status": "dynamic"}, + "owner_span": {"span": {**md["span"], "start_byte": 999999}}, "lexical": {"lexical_shadowed": True}} + owner["metadata"]["qml"] = encode_metadata({**md, **fields[corruption]}) + assert not accepted(result, edge) + + +@pytest.mark.parametrize("profile", ["native", "reverse"]) +def test_endpoint_role_cannot_be_changed_to_repair_relation(tmp_path, profile): + """Changing the proof's display kind cannot replace the original signal/property/function role.""" + result = fixture(tmp_path, profile) + edge = selected(result, "qml_js_call" if profile == "native" else "qt_qml_connect_signal") + assert accepted(result, edge) + if profile == "native": + md = qt_metadata(edge) + proof = copy.deepcopy(md["native_endpoint"]) + proof["kind"] = "signal" + update_qt(edge, native_endpoint=proof) + edge["relation"], edge["context"] = "uses", "qml_signal_emit" + else: + source = next(node for node in result["nodes"] if node["id"] == edge["source"]) + update_qt(source, role="receiver") + edge["context"] = "qt_qml_connect_receiver" + assert not accepted(result, edge) + + +@pytest.mark.parametrize("profile,context", [case for case in CASES if case[0] != "native"]) +def test_cross_language_flow_cannot_be_reversed_by_metadata(tmp_path, profile, context): + """Dependency endpoints and the observed QML/C++ flow retain separate, consistent authority.""" + result = fixture(tmp_path, profile) + edge = selected(result, context) + assert accepted(result, edge) + old = qt_metadata(edge)["bridge_direction"] + update_qt(edge, bridge_direction="cpp_to_qml" if old == "qml_to_cpp" else "qml_to_cpp") + assert not accepted(result, edge) + assert not build_from_json(result, root=tmp_path).has_edge(edge["source"], edge["target"]) + + +@pytest.mark.parametrize("field", ["conditional", "reflection_supported", "operation"]) +def test_reverse_reflection_repeats_original_supported_operation(tmp_path, field): + """Resolved transport cannot repair a newly conditional or rejected reflection operation.""" + result = fixture(tmp_path, "reverse") + edge = selected(result, "qt_cpp_qml_property_read") + source = next(node for node in result["nodes"] if node["id"] == edge["source"]) + assert accepted(result, edge) + update_qt(source, **{field: {"conditional": True, "reflection_supported": False, "operation": "invokeMethod"}[field]}) + assert not accepted(result, edge) + assert not build_from_json(result, root=tmp_path).has_edge(edge["source"], edge["target"]) diff --git a/tests/test_qt_combined_adoption.py b/tests/test_qt_combined_adoption.py new file mode 100644 index 0000000000..337c39f3eb --- /dev/null +++ b/tests/test_qt_combined_adoption.py @@ -0,0 +1,79 @@ +"""REQ-QML-018-AC05/AC06/AC07: combined profile through actual CLI and durable consumers.""" +from __future__ import annotations + +import json +import os + +import pytest + +import graphify.extract as extraction +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.paths import load_node_link_graph + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_combined_adoption_fixture import corpus +from tests.test_qt_final_incremental_parity import normalized, run + + +def accepted(graph): + """Assert exact source-backed APIs, subscriptions and constructor emission ownership.""" + bindings = [qt_metadata(node) for _, node in graph.nodes(data=True) + if qt_metadata(node).get("kind") == "context_binding"] + assert len(bindings) == 1 + accesses = [qt_metadata(node) for _, node in graph.nodes(data=True) + if qt_metadata(node).get("kind") == "context_access"] + assert len(accesses) >= 3 and all(md["status"] == "resolved" for md in accesses) + subscriptions = [(identity, qt_metadata(node)) for identity, node in graph.nodes(data=True) + if qt_metadata(node).get("kind") == "context_subscription"] + assert len(subscriptions) == 2 and all(md["status"] == "resolved" for _, md in subscriptions) + for identity, _ in subscriptions: + assert not any(edge.get("relation") == "calls" for _, _, edge in graph.edges(identity, data=True)) + emissions = [qt_metadata(node) for _, node in graph.nodes(data=True) + if qt_metadata(node).get("kind") == "emission"] + assert len(emissions) == 1 and emissions[0]["status"] == "resolved" + owner = graph.nodes[emissions[0]["owner_id"]] + assert owner.get("metadata", {}).get("cpp_constructor") + assert qt_metadata(graph.nodes[emissions[0]["target_id"]])["class_name"] == "Grand" + imports = [qml_metadata(node) for _, node in graph.nodes(data=True) + if qml_metadata(node).get("kind") == "import_resolution" + and node.get("label") == "Public.Extra"] + assert imports and all(md["status"] == "resolved" for md in imports) + + +def clean(root, cache): + """Use the same explicit analysis roots as the CLI; the facade owns no ambient config.""" + paths = extraction.collect_files(root, root=root) + result = extraction.extract(paths, root=root, cache_root=cache, parallel=False, + qml_import_roots=tuple(json.loads(os.environ["GRAPHIFY_QML_IMPORT_ROOTS"]))) + assert not result["failed_sources"] and not result["qml_failures"] + graph = build_from_json(result, root=root) + path = root / "graphify-out/combined-comparison.json" + assert to_json(graph, {}, str(path), force=True) + return load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + + +@pytest.mark.parametrize("build_system", ["cmake", "qmake"]) +@pytest.mark.parametrize("scope", ["whole", "subfolder"]) +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac05_combined_configured_project_matches_cold_warm_and_updates(tmp_path, monkeypatch, build_system, scope, operation): + """Each explicit root has its own stable IDs; no cross-root rebasing is inferred.""" + app = corpus(tmp_path, build_system) + root = tmp_path if scope == "whole" else app + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["app/imports"]' if scope == "whole" else '["imports"]') + baseline = clean(root, root / ".cold-cache") + accepted(baseline) + assert normalized(clean(root, root / ".cold-cache")) == normalized(baseline) + actual = run(root, monkeypatch, operation) + accepted(actual) + assert normalized(actual) == normalized(baseline) + assert normalized(run(root, monkeypatch, operation, [])) == normalized(actual) + # QML-only edits rejoin provider and callback declarations from unchanged C++. + path = app / "Main.qml" + path.write_bytes(path.read_bytes().replace(b"handleReady", b"received")) + edited = run(root, monkeypatch, operation, [path]) + accepted(edited) + assert normalized(edited) == normalized(clean(root, root / ".edited-cache")) diff --git a/tests/test_qt_combined_adoption_safety.py b/tests/test_qt_combined_adoption_safety.py new file mode 100644 index 0000000000..cb632410f0 --- /dev/null +++ b/tests/test_qt_combined_adoption_safety.py @@ -0,0 +1,84 @@ +"""REQ-QML-018-AC06: combined installed profile retains products at real failure boundaries.""" +from __future__ import annotations + +import builtins +import os +import stat + +import pytest + +from graphify.cache import cache_dir +from tests.qt_combined_adoption_fixture import corpus +from tests.test_qt_combined_adoption import accepted, clean +from tests.test_qt_final_incremental_parity import normalized, run +from tests.test_qt_product_publication import reject, snapshot + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("failure", ["missing_optional_parser", "malformed_qml", "unsafe_import_root"]) +def test_req_qml018_ac06_combined_rejection_retains_cache_products_and_retries(tmp_path, monkeypatch, capsys, operation, failure): + """Actual parser import/syntax or configured-root rejection cannot accept a new graph.""" + app = corpus(tmp_path, "qmake") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["app/imports"]') + initial = run(tmp_path, monkeypatch, operation) + accepted(initial) + before = snapshot(tmp_path / "graphify-out") + cache = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*") if path.is_file()} + assert cache + qml = app / "Main.qml" + original = qml.read_bytes() + qml.write_bytes(original + (b"Item {\n" if failure == "malformed_qml" else b"\n// changed\n")) + with monkeypatch.context() as broken: + if failure == "missing_optional_parser": + original_import = builtins.__import__ + def absent(name, *args, **kwargs): + if name == "tree_sitter_language_pack": + raise ModuleNotFoundError("unavailable optional parser") + return original_import(name, *args, **kwargs) + broken.setattr(builtins, "__import__", absent) + elif failure == "unsafe_import_root": + broken.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["../outside"]') + reject(tmp_path, broken, operation, qml) + assert snapshot(tmp_path / "graphify-out") == before + assert all(path.exists() and path.read_bytes() == value for path, value in cache.items()) + output = capsys.readouterr().out + assert ("QML_GRAPH_PRESERVED" if failure != "unsafe_import_root" else "QT_CONFIG") in output + qml.write_bytes(original) + recovered = run(tmp_path, monkeypatch, operation, [qml]) + accepted(recovered) + assert normalized(recovered) == normalized(initial) + assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".retry-cache")) + completed = snapshot(tmp_path / "graphify-out") + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered) + assert snapshot(tmp_path / "graphify-out") == completed + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only destination") +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac06_combined_readonly_destination_preserves_then_retries(tmp_path, monkeypatch, operation): + """A real persisted graph failure occurs after successful combined source analysis.""" + app = corpus(tmp_path, "cmake") + monkeypatch.chdir(app) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["imports"]') + initial = run(app, monkeypatch, operation) + accepted(initial) + before = snapshot(app / "graphify-out") + qml = app / "Main.qml" + qml.write_bytes(qml.read_bytes().replace(b"handleReady", b"received")) + target = app / "graphify-out/graph.json" + target.chmod(stat.S_IREAD) + try: + reject(app, monkeypatch, operation, qml) + assert snapshot(app / "graphify-out") == before + finally: + target.chmod(stat.S_IWRITE) + recovered = run(app, monkeypatch, operation, [qml]) + accepted(recovered) + assert normalized(recovered) != normalized(initial) + assert normalized(recovered) == normalized(clean(app, app / ".retry-cache")) + after = snapshot(app / "graphify-out") + assert normalized(run(app, monkeypatch, operation, [])) == normalized(recovered) + assert snapshot(app / "graphify-out") == after diff --git a/tests/test_qt_config_incremental.py b/tests/test_qt_config_incremental.py new file mode 100644 index 0000000000..d0490513cd --- /dev/null +++ b/tests/test_qt_config_incremental.py @@ -0,0 +1,157 @@ +"""Real Qt configuration refresh and successful-publication stamp ordering.""" +from __future__ import annotations + +import json +import pytest + +import graphify.__main__ as mainmod +import graphify.extract as extraction +import graphify.qt_analysis_state as state +from graphify.extractors.qml_facts import qml_metadata +from graphify.paths import load_node_link_graph +from graphify.watch import _rebuild_code + + +def _write(root, path, source): + destination = root / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(source, encoding="utf-8") + return destination + + +def _project(root): + _write(root, "Main.qml", "import Public.Tools 1.0\nService { property int displayed: value }\n") + _write(root, "keep.py", "\n".join(f"def retained_{index}(): return {index}" for index in range(20))) + for directory, value in (("first", 1), ("second", 2)): + _write(root, f"{directory}/Public/Tools/qmldir", "module Public.Tools\nService 1.0 Service.qml\n") + _write(root, f"{directory}/Public/Tools/Service.qml", f"QtObject {{ property int value: {value} }}\n") + + +def _run(root, monkeypatch, operation, *, initial=False, changes=None, force=False): + if operation == "watch": + return _rebuild_code(root, changed_paths=None if initial else (changes or []), + no_cluster=True, acquire_lock=False, force=force) + argv = ["graphify", "extract", str(root), "--code-only", "--no-cluster"] + if force: + argv += ["--force", "--allow-partial"] + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod, "_refresh_stale_skills", lambda: None) + monkeypatch.setattr(mainmod.sys, "argv", argv) + try: + mainmod.main() + except SystemExit as exc: + return exc.code in (None, 0) + return True + + +def _graph(root): + return load_node_link_graph(json.loads((root / "graphify-out/graph.json").read_text(encoding="utf-8"))) + + +def _provider(root): + graph = _graph(root) + sites = [identity for identity, node in graph.nodes(data=True) + if node.get("source_file") == "Main.qml" and qml_metadata(node).get("kind") == "type_use"] + assert len(sites) == 1 + assert qml_metadata(graph.nodes[sites[0]])["status"] == "resolved" + links = [data.get("_tgt", target) for source, target, data in graph.edges(data=True) + if data.get("_src", source) == sites[0] and data.get("relation") == "uses"] + assert len(links) == 1 + return graph.nodes[links[0]]["source_file"] + + +def _stamp(root): + return json.loads((root / "graphify-out" / state.QT_STATE_FILE).read_text(encoding="utf-8")) + + +def _snapshot(root): + out = root / "graphify-out" + return {name: (out / name).read_bytes() for name in + ("graph.json", "manifest.json", state.QT_STATE_FILE)} + + +@pytest.mark.parametrize("operation", ["watch", "extract"]) +def test_qml011_ac03_import_root_order_refreshes_unchanged_source_provider(tmp_path, monkeypatch, operation): + _project(tmp_path) + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["first", "second"]') + assert _run(tmp_path, monkeypatch, operation, initial=True) + assert _provider(tmp_path) == "first/Public/Tools/Service.qml" + before = _stamp(tmp_path) + observed, real = [], extraction.extract + + def observe(paths, **kwargs): + observed.extend(paths) + return real(paths, **kwargs) + + monkeypatch.setattr(extraction, "extract", observe) + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["second", "first"]') + assert _run(tmp_path, monkeypatch, operation) + assert tmp_path / "Main.qml" in observed + assert _provider(tmp_path) == "second/Public/Tools/Service.qml" + assert _stamp(tmp_path)["fingerprint"] != before["fingerprint"] + + +@pytest.mark.parametrize("operation", ["watch", "extract"]) +def test_qml011_ac03_package_version_change_reanalyzes_unchanged_corpus(tmp_path, monkeypatch, operation): + _write(tmp_path, "Main.qml", "QtObject { property int value: 1 }\n") + assert _run(tmp_path, monkeypatch, operation, initial=True) + before = _stamp(tmp_path) + real_version, real_extract, observed = state.version, extraction.extract, [] + + def changed_version(package): + original = real_version(package) + return original + "+contract-probe" if package == "tree-sitter" else original + + def observe(paths, **kwargs): + observed.extend(paths) + return real_extract(paths, **kwargs) + + monkeypatch.setattr(state, "version", changed_version) + monkeypatch.setattr(extraction, "extract", observe) + assert _run(tmp_path, monkeypatch, operation) + assert tmp_path / "Main.qml" in observed + assert _stamp(tmp_path)["fingerprint"] != before["fingerprint"] + assert all(qml_metadata(node).get("contract_version") == 1 for _, node in _graph(tmp_path).nodes(data=True)) + + +@pytest.mark.parametrize("operation", ["watch", "extract"]) +def test_qml011_ac02_last_qt_source_deletion_cleans_facts_and_commits_nonqt_state(tmp_path, monkeypatch, operation): + source = _write(tmp_path, "Main.qml", "QtObject { property int value: 1 }\n") + _write(tmp_path, "keep.py", "\n".join(f"def retained_{index}(): return {index}" for index in range(20))) + assert _run(tmp_path, monkeypatch, operation, initial=True) + assert _stamp(tmp_path)["has_qt"] is True + source.unlink() + assert _run(tmp_path, monkeypatch, operation, changes=[source]) + graph = _graph(tmp_path) + assert not any(qml_metadata(node) for _, node in graph.nodes(data=True)) + assert any(node.get("label") == "retained_0()" for _, node in graph.nodes(data=True)) + assert _stamp(tmp_path)["has_qt"] is False + before = _snapshot(tmp_path) + assert _run(tmp_path, monkeypatch, operation) + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize("operation", ["watch", "extract"]) +@pytest.mark.parametrize("name,invalid", [ + ("Broken.qml", "Item { property int value: }"), + ("broken.qrc", ']>'), + ("new.cmake", "if(ENABLE)\nqt_add_qml_module(app URI Public.Tools)\nendif()"), +]) +def test_qml012_ac02_malformed_new_source_preserves_graph_manifest_and_stamp(tmp_path, monkeypatch, operation, name, invalid): + _write(tmp_path, "Main.qml", "QtObject { property int value: 1 }\n") + assert _run(tmp_path, monkeypatch, operation, initial=True) + before = _snapshot(tmp_path) + source = _write(tmp_path, name, invalid) + assert not _run(tmp_path, monkeypatch, operation, changes=[source], force=True) + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize("operation", ["watch", "extract"]) +@pytest.mark.parametrize("config", ["not JSON", '["../outside"]', '["/absolute"]']) +def test_qml012_ac02_invalid_import_configuration_preserves_prior_stamp(tmp_path, monkeypatch, operation, config): + _write(tmp_path, "Main.qml", "QtObject { property int value: 1 }\n") + assert _run(tmp_path, monkeypatch, operation, initial=True) + before = _snapshot(tmp_path) + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", config) + assert not _run(tmp_path, monkeypatch, operation, force=True) + assert _snapshot(tmp_path) == before diff --git a/tests/test_qt_construction_authority.py b/tests/test_qt_construction_authority.py new file mode 100644 index 0000000000..c3ed238634 --- /dev/null +++ b/tests/test_qt_construction_authority.py @@ -0,0 +1,143 @@ +"""REQ-QML-017: construction ownership needs non-widget QObject ancestry.""" +from __future__ import annotations + +import copy + +import pytest + +from graphify.extractors.qml import extract_qml +from graphify.extractors.qt_cpp_facts import encode_qt, qt_metadata +from graphify.qml_resolution_types import qml_metadata +from graphify.qt_qml_access_index import QtQmlAccessIndex +from tests.test_qt_receiver_boundaries import persisted + + +QML = ('import QtQuick\nimport Public.Tools 1.0\nItem { ' + 'property Owned child: Owned { objectName: "child"; property int count: 1 } }') + + +def native(root, declarations, *, extra=None): + sources = {"backend.h": declarations, "CMakeLists.txt": + 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h)'} + return persisted(root, 'root->findChild("child");', QML, + extra={**sources, **(extra or {})}) + + +@pytest.mark.parametrize("declarations", [ + 'class Backend : public QWidget { Q_OBJECT QML_NAMED_ELEMENT(Owned) };', + 'class Base : public QWidget { Q_OBJECT }; ' + 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) };', + 'class Backend : public QQuickWidget { Q_OBJECT QML_NAMED_ELEMENT(Owned) };', + 'class Backend : public ExternalBase { Q_OBJECT QML_NAMED_ELEMENT(Owned) };', + 'namespace Other { class QObject : public QWidget { Q_OBJECT }; } ' + 'class Backend : public Other::QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) };', + 'class QObject : public QWidget { Q_OBJECT }; ' + 'class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) };', +]) +def test_req_qml017_ac02_widget_and_unknown_ancestry_cannot_authorize_child_tree(tmp_path, declarations): + result, site, links = native(tmp_path, declarations) + reason = "find_child_type_unsupported" if declarations.startswith("class QObject :") else "native_construction_ancestry_unestablished" + assert not links and qt_metadata(site)["reason"] == reason + # The registration/declaration remains observed; rejecting construction + # authority must not erase its source fact or invent a substitute endpoint. + assert any(qt_metadata(node).get("kind") == "registration" for node in result["nodes"]) + child = next(node for node in result["nodes"] if qml_metadata(node).get("type_name") == "Owned") + assert qml_metadata(child)["construction_parent_scope_key"] + + +@pytest.mark.parametrize("declarations", [ + 'class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) };', + 'class Base : public QObject { Q_OBJECT }; ' + 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) };', + 'namespace Public { class Base : public QObject { Q_OBJECT }; ' + 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }', + 'class Base : public QObject { Q_OBJECT }; ' + 'namespace Public { class Backend : public ::Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }', + 'class Interface {}; class Backend : public QObject, public Interface { Q_OBJECT QML_NAMED_ELEMENT(Owned) };', +]) +def test_req_qml017_ac04_direct_and_source_defined_qobject_chain_survives_publication(tmp_path, declarations): + result, site, links = native(tmp_path, declarations) + assert qt_metadata(site)["status"] == "resolved" and len(links) == 1 + child = next(node for node in result["nodes"] if node["id"] == links[0]["target"]) + assert qml_metadata(child)["type_name"] == "Owned" and child["source_file"] == "Main.qml" + + +def test_req_qml017_ac04_exact_namespace_ancestry_does_not_choose_other_file_basename(tmp_path): + declarations = ('namespace Public { class Base : public QObject { Q_OBJECT }; ' + 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }') + result, site, links = native(tmp_path, declarations, + extra={"other.h": 'class Base : public QWidget { Q_OBJECT };'}) + assert qt_metadata(site)["status"] == "resolved" and len(links) == 1 + bases = [qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "class" + and qt_metadata(node).get("class_name") in {"Base", "Public::Base"}] + assert len(bases) == 2 and len({md["class_id"] for md in bases}) == 2 + + +def test_req_qml017_ac04_distinct_same_file_qualified_bodies_supply_only_exact_ancestry(tmp_path): + declarations = ('class Base : public QWidget { Q_OBJECT }; ' + 'namespace Public { class Base : public QObject { Q_OBJECT }; ' + 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }') + result, site, links = native(tmp_path, declarations) + assert qt_metadata(site)["status"] == "resolved" and len(links) == 1 + bases = [qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "class" + and qt_metadata(node).get("class_name") in {"Base", "Public::Base"}] + assert len(bases) == 2 and len({md["class_id"] for md in bases}) == 2 + assert {md["class_name"]: md["canonical_base_names"] for md in bases} == { + "Base": ["QWidget"], "Public::Base": ["QObject"]} + assert not any(node.get("metadata", {}).get("cpp_class", {}).get("ambiguous") for node in result["nodes"]) + + +@pytest.mark.parametrize("alias", ["using QObject = QWidget;", "using QObject = QQuickWidget;"]) +def test_req_qml017_ac02_widget_alias_cannot_inherit_global_qobject_authority(tmp_path, alias): + declarations = ('namespace Public { ' + alias + + ' class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }') + result, site, links = native(tmp_path, declarations) + assert not links and qt_metadata(site)["reason"] == "native_construction_ancestry_unestablished" + md = next(qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "class") + assert md["bases"] == ["QObject"] and md["canonical_base_names"] != ["QObject"] + + +@pytest.mark.parametrize("corruption", ["definition", "status", "origin", "identity", "base", "canonical_status", "canonical_missing"]) +def test_req_qml017_ac02_native_ancestry_reads_only_accepted_complete_source_facts(tmp_path, corruption): + result, _, _ = native(tmp_path, 'class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) };') + nodes, edges = copy.deepcopy(result["nodes"]), copy.deepcopy(result["edges"]) + declaration = next(node for node in nodes if qt_metadata(node).get("kind") == "class") + md = qt_metadata(declaration) + md.pop("raw_values", None) + if corruption == "origin": + declaration["_origin"] = "llm" + else: + key, value = {"definition": ("is_definition", False), "status": ("status", "unavailable"), + "identity": ("class_id", "unaccepted"), "base": ("canonical_base_names", ["Unknown"]), + "canonical_status": ("canonical_base_status", "unavailable"), + "canonical_missing": ("canonical_base_names", None)}[corruption] + declaration["metadata"]["qt"] = encode_qt({**md, key: value}) + before = copy.deepcopy((nodes, edges)) + index = QtQmlAccessIndex(nodes, edges, root=tmp_path) + root = next(node for node in nodes if qml_metadata(node).get("kind") == "object" + and not qml_metadata(node).get("parent_scope_key")) + assert index.find_child(root["id"], "child").target_id is None + assert (nodes, edges) == before + + +@pytest.mark.parametrize("count", [50, 51]) +def test_req_qml017_ac04_parent_mutation_spans_are_bounded_original_bytes(tmp_path, count): + source = ('import QtQuick\r\nItem { id: left; Item { id: right } function change() { ' + + 'left.parent = right; ' * count + '} }').encode() + path = tmp_path / "Main.qml" + path.write_bytes(source) + result = extract_qml(path, root=tmp_path) + if count == 51: + assert result["nodes"] == [] and result["edges"] == [] + assert result["error"].startswith("QML_LIMIT") + assert result["diagnostics"][0]["code"] == "QML_LIMIT" + else: + assert not result.get("error") + objects = [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == "object"] + assert objects and all(md.get("construction_parent_dynamic") for md in objects) + for md in objects: + spans = md["construction_parent_mutation_spans"] + assert len(spans) == 50 + for span in spans: + assert source[span["start_byte"]:span["end_byte"]] == b"left.parent = right" + assert span["start_row"] == source[:span["start_byte"]].count(b"\n") diff --git a/tests/test_qt_constructor_ownership.py b/tests/test_qt_constructor_ownership.py new file mode 100644 index 0000000000..8c8984b9ba --- /dev/null +++ b/tests/test_qt_constructor_ownership.py @@ -0,0 +1,178 @@ +"""REQ-QML-008/016/017: constructor ownership reaches durable Qt consumers.""" +import json + +import pytest + +from graphify.affected import affected_nodes +from graphify.build import build_from_json +from graphify.cluster import cluster +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.serve import _query_graph_text +from tests.qt_analysis_helpers import analysis, sites + + +HEADER = '''class Backend : public QObject { + Q_OBJECT +public: + explicit Backend(QObject *parent = nullptr); +signals: + void changed(); +}; +''' +BODY = '''#include "backend.hpp" +Backend::Backend(QObject *parent) : QObject(parent) { + QQmlApplicationEngine engine; + engine.load("qrc:/App/Main.qml"); + QObject *root = engine.rootObjects().first(); + root->setProperty("value", 2); + emit changed(); +} +''' + + +def fixture(root, header=HEADER, body=BODY): + """An accepted literal resource supplies reverse-QML targets without executing Qt.""" + return analysis(root, {"backend.hpp": header, "backend.cpp": body, + "Main.qml": "import QtQml\nQtObject { property int value: 1 }", + "app.qrc": 'Main.qml'}) + + +def constructor(result): + candidates = [node for node in result["nodes"] if node.get("_callable") and not node.get("_callable_class") + and (node.get("definition_file") == "backend.cpp" or node.get("source_file") == "backend.cpp")] + assert len(candidates) == 1 + return candidates[0] + + +@pytest.mark.parametrize("namespace", [False, True]) +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml016_ac01_and_qml017_ac02_constructor_owns_emission_and_write_after_json_reload(tmp_path, namespace, directed): + """Original constructor identity owns every site through assembly, publication and real consumers.""" + header = f"namespace Shared {{\n{HEADER}}}\n" if namespace else HEADER + body = BODY.replace("Backend::Backend", "Shared::Backend::Backend") if namespace else BODY + result = fixture(tmp_path, header, body) + method = constructor(result) + owned = sites(result, "qml_load") + sites(result, "qml_root") + sites(result, "qml_access") + sites(result, "emission") + assert len(owned) == 4 + assert all(qt_metadata(node)["owner_id"] == method["id"] and qt_metadata(node)["status"] == "resolved" for node in owned) + emission = sites(result, "emission")[0] + signal = qt_metadata(emission)["target_id"] + original = (tmp_path / "backend.cpp").read_bytes() + span = qt_metadata(emission)["span"] + assert original[span["start_byte"]:span["end_byte"]] == b"changed()" + graph = build_from_json(result, root=tmp_path, directed=directed) + path = tmp_path / "graph.json" + assert to_json(graph, cluster(graph), str(path)) + reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + assert all(reloaded.has_edge(method["id"], node["id"]) for node in owned) + assert all(qt_metadata(reloaded.nodes[node["id"]])["owner_id"] == method["id"] for node in owned) + assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(emission["id"], data=True)) + assert method["id"] in {hit.node_id for hit in affected_nodes(reloaded, signal, depth=3)} + answer = _query_graph_text(reloaded, "Backend changed", token_budget=4000) + assert "backend.cpp" in answer and "Qt emission: changed" in answer + + +def test_req_qml008_ac02_constructor_spans_use_original_bom_crlf_unicode_bytes(tmp_path): + """No normalized-newline or display-string offset may replace producer byte positions.""" + body = ("\ufeff// caf\u00e9 \u96ea\n" + BODY).replace("\n", "\r\n") + result = fixture(tmp_path, body=body) + method = constructor(result) + assert method["definition_file"] == "backend.cpp" and method["definition_location"] == "L3" + original = (tmp_path / "backend.cpp").read_bytes() + assert original == body.encode("utf-8") + for node in sites(result, "emission") + sites(result, "qml_access"): + metadata = qt_metadata(node) + assert metadata["owner_id"] == method["id"] + span = metadata["span"] + expected = b"changed()" if metadata["kind"] == "emission" else b'root->setProperty("value", 2)' + assert original[span["start_byte"]:span["end_byte"]] == expected + + +@pytest.mark.parametrize("control", ["duplicate_body", "foreign_namespace", "duplicate_signature"]) +def test_req_qml016_ac04_constructor_conflicts_cannot_invent_a_canonical_owner(tmp_path, control): + """Duplicate classes or signatures remain visible without canonical native authority.""" + header, body = HEADER, BODY + if control == "foreign_namespace": + header = f"namespace Other {{\n{HEADER}}}\n" + body = BODY.replace("Backend::Backend", "Foreign::Backend::Backend") + elif control == "duplicate_signature": + header = HEADER.replace("signals:", " explicit Backend(QObject *owner);\nsignals:") + sources = {"backend.hpp": header, "backend.cpp": body} + if control == "duplicate_body": + sources["duplicate.hpp"] = header + sources.update({"Main.qml": "import QtQml\nQtObject { property int value: 1 }", + "app.qrc": 'Main.qml'}) + result = analysis(tmp_path, sources) + method = constructor(result) + implementation = next(node for node in sites(result, "member") if node["source_file"] == "backend.cpp") + assert qt_metadata(implementation)["class_id"] == "" + assert not any(edge["target"] == method["id"] and edge["relation"] == "method" for edge in result["edges"]) + emission = qt_metadata(sites(result, "emission")[0]) + assert emission["owner_id"] == method["id"] and emission["status"] != "resolved" and emission["target_id"] == "" + # Independently proved source callables and literal resource handles do not + # need native class authority to own a QML write in the same body. + access = qt_metadata(sites(result, "qml_access")[0]) + assert access["owner_id"] == method["id"] and access["status"] == "resolved" + + +def test_req_qml008_ac02_delegating_overload_keeps_exact_native_site_owner(tmp_path): + """Distinct signatures authorize the zero-argument body, without a guessed delegation call.""" + header = HEADER.replace(" explicit Backend", " Backend();\n explicit Backend") + body = BODY.replace("Backend::Backend(QObject *parent) : QObject(parent)", + "Backend::Backend() : Backend(nullptr)") + result = fixture(tmp_path, header, body) + method = constructor(result) + implementation = next(node for node in sites(result, "member") if node["source_file"] == "backend.cpp") + assert qt_metadata(implementation)["class_id"] + assert any(edge["target"] == method["id"] and edge["relation"] == "method" for edge in result["edges"]) + emission = qt_metadata(sites(result, "emission")[0]) + assert emission["owner_id"] == method["id"] and emission["status"] == "resolved" and emission["target_id"] + other_constructors = {node["id"] for node in result["nodes"] + if node.get("metadata", {}).get("cpp_constructor") and node["id"] != method["id"]} + assert not any(edge["source"] == method["id"] and edge["target"] in other_constructors + and edge["relation"] == "calls" for edge in result["edges"]) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml016_ac04_and_qml017_ac04_constructor_updates_remove_stale_sites_and_preserve_failures(tmp_path, monkeypatch, operation): + """Real cold/warm, CLI/watch, edit/removal and failed publication keep exact source ownership.""" + from tests.test_qt_final_incremental_parity import clean, normalized, run + + fixture(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + cache = tmp_path / ".comparison-cache" + cold = clean(tmp_path, cache) + assert normalized(clean(tmp_path, cache)) == normalized(cold) + initial = run(tmp_path, monkeypatch, operation) + assert normalized(initial) == normalized(cold) + paths = [tmp_path / "backend.hpp", tmp_path / "backend.cpp"] + for path in paths: + path.write_text(path.read_text(encoding="utf-8").replace("parent", "owner"), encoding="utf-8", newline="") + changed = run(tmp_path, monkeypatch, operation, paths) + assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".edited-cache")) + emissions = [identity for identity, data in changed.nodes(data=True) if qt_metadata(data).get("kind") == "emission"] + assert len(emissions) == 1 and qt_metadata(changed.nodes[emissions[0]])["owner_id"] + cpp = paths[1] + cpp.write_text(cpp.read_text(encoding="utf-8").replace(" emit changed();\n", ""), encoding="utf-8", newline="") + removed = run(tmp_path, monkeypatch, operation, [cpp]) + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache")) + assert not any(qt_metadata(data).get("kind") == "emission" for _, data in removed.nodes(data=True)) + output = tmp_path / "graphify-out" + retained = {name: (output / name).read_bytes() for name in ("graph.json", "manifest.json", ".qt_analysis.json")} + valid = cpp.read_bytes() + cpp.write_bytes(valid + b"\nvoid damaged( {\n") + if operation == "manual": + with pytest.raises(SystemExit) as failure: + run(tmp_path, monkeypatch, operation, [cpp]) + assert failure.value.code == 1 + else: + from graphify.watch import _rebuild_code + assert not _rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True) + assert retained == {name: (output / name).read_bytes() for name in retained} + cpp.write_bytes(valid) + corrected = run(tmp_path, monkeypatch, operation, [cpp]) + assert normalized(corrected) == normalized(removed) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(corrected) diff --git a/tests/test_qt_context_callback_integrity.py b/tests/test_qt_context_callback_integrity.py new file mode 100644 index 0000000000..d8fdf6fad8 --- /dev/null +++ b/tests/test_qt_context_callback_integrity.py @@ -0,0 +1,104 @@ +"""REQ-QML-018-AC04/AC06: subscriptions retain their exact source callback.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qml_facts import encode_metadata, qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt +from graphify.paths import load_node_link_graph +from graphify.qt_qml_projection import allows_qt_qml_edge +from tests.qt_adoption_fixture import QML, sources +from tests.qt_analysis_helpers import analysis + + +def callback_view(tmp_path, result, stage, form): + """Exercise raw facts and both graph orientations after real serialization.""" + if stage == "raw": + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + by_id = {node["id"]: node for node in nodes} + else: + graph = build_from_json(result, root=tmp_path, directed=stage == "directed") + path = tmp_path / "callbacks.json" + assert to_json(graph, {}, str(path), force=True) + restored = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + by_id = restored.nodes + edges = [{**attrs, "source": attrs.get("_src", source), "target": attrs.get("_tgt", target)} + for source, target, attrs in restored.edges(data=True)] + edge = next(item for item in edges if item.get("context") == "qt_context_subscription" + and qt_metadata(by_id[item["source"]]).get("subscription_form") == form) + source_id, target_id = edge["source"], edge["target"] + def accepted(): + return allows_qt_qml_edge(by_id[source_id], by_id[target_id], edge, + source_id=source_id, target_id=target_id, nodes=by_id) + assert accepted() + return by_id, by_id[source_id], accepted + + +@pytest.mark.parametrize("stage", ["raw", "undirected", "directed"]) +@pytest.mark.parametrize("form", ["signal.connect", "Connections"]) +def test_req_qml018_ac04_ac06_callback_target_cannot_be_substituted(tmp_path, stage, form): + """Changing only the derived target to a real sibling function must invalidate the bridge.""" + nodes, subscription, accepted = callback_view(tmp_path, analysis(tmp_path, sources(tmp_path)), stage, form) + replacement = next(nid for nid, node in nodes.items() if qml_metadata(node).get("kind") == "function" + and qml_metadata(node).get("raw_name") == "run") + update_qt(subscription, handler_target_id=replacement) + assert not accepted() + + +@pytest.mark.parametrize("stage", ["raw", "undirected", "directed"]) +def test_req_qml018_ac04_ac06_local_callback_precedes_same_named_qml_function(tmp_path, stage): + """The captured local binder remains accepted; a same-name QML function cannot replace it.""" + qml = QML.replace("function run() {", "function run() { function handleReady() {}") + nodes, subscription, accepted = callback_view(tmp_path, analysis(tmp_path, sources(tmp_path, qml=qml)), stage, "signal.connect") + assert qml_metadata(nodes[qt_metadata(subscription)["handler_target_id"]])["kind"] == "js_function" + replacement = next(nid for nid, node in nodes.items() if qml_metadata(node).get("kind") == "function" + and qml_metadata(node).get("raw_name") == "handleReady") + update_qt(subscription, handler_target_id=replacement) + assert not accepted() + + +@pytest.mark.parametrize("corruption", ["reference", "arity", "lexical", "name", "scope", "span", "handler_span", "form", "owner"]) +def test_req_qml018_ac06_callback_borrowed_provenance_is_revalidated(tmp_path, corruption): + """Accepted callback IDs do not repair changed call, declaration, scope or site ownership.""" + nodes, subscription, accepted = callback_view(tmp_path, analysis(tmp_path, sources(tmp_path)), "raw", "signal.connect") + md = qt_metadata(subscription) + call, handler = nodes[md["owner_id"]], nodes[md["handler_target_id"]] + if corruption in {"form", "owner"}: + update_qt(subscription, **({"subscription_form": "Connections"} if corruption == "form" else {"owner_id": md["handler_target_id"]})) + elif corruption in {"span", "handler_span"}: + span = md["span"] if corruption == "span" else qml_metadata(handler)["span"] + span["start_byte"] += 1 if corruption == "span" else 1_000_000 + if corruption == "handler_span": + span["end_byte"] += 1_000_000 + fields = {key: value for key, value in qml_metadata(handler).items() if key != "raw_values"} + fields["span"] = span + handler["metadata"]["qml"] = encode_metadata(fields) + else: + update_qt(subscription, span=span) + else: + chosen = handler if corruption in {"name", "scope"} else call + fields = {key: value for key, value in qml_metadata(chosen).items() if key != "raw_values"} + fields.update({"reference": {"callback_reference": "run"}, "arity": {"callback_argument_count": 2}, + "lexical": {"callback_lexical_shadowed": True, "callback_lexical_target_id": ""}, + "name": {"raw_name": "unrelated"}, "scope": {"object_scope_key": "unrelated"}}[corruption]) + chosen["metadata"]["qml"] = encode_metadata(fields) + assert not accepted() + + +@pytest.mark.parametrize("stage", ["raw", "undirected", "directed"]) +@pytest.mark.parametrize("callback", ["qualified", "own_scope"]) +def test_req_qml018_ac04_callback_lookup_retains_object_scope(tmp_path, stage, callback): + """A same-name root function cannot displace an own member or qualified ID member.""" + use = "backend.service.ready.connect(" + ("receiver.handleReady" if callback == "qualified" else "handleReady") + ")" + child = " property QtObject nested: QtObject { id: receiver; function handleReady() {}\n" + qml = "import QtQml\nQtObject {\n function handleReady() {}\n" + child + qml += (" }\n function run() { " + use + " }\n" if callback == "qualified" else " function run() { " + use + " }\n }\n") + "}\n" + nodes, subscription, _ = callback_view(tmp_path, analysis(tmp_path, sources(tmp_path, qml=qml)), stage, "signal.connect") + selected = qml_metadata(nodes[qt_metadata(subscription)["handler_target_id"]]) + receiver = next(qml_metadata(node) for node in nodes.values() if qml_metadata(node).get("object_id") == "receiver") + assert selected["object_scope_key"] == receiver["object_scope_key"] diff --git a/tests/test_qt_context_implicit_subscriptions.py b/tests/test_qt_context_implicit_subscriptions.py new file mode 100644 index 0000000000..e93bdcd6ff --- /dev/null +++ b/tests/test_qt_context_implicit_subscriptions.py @@ -0,0 +1,36 @@ +"""REQ-QML-018-AC04: native legacy parameters precede nested subscription lookup.""" +from __future__ import annotations + +import pytest + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_adoption_fixture import NATIVE, sources +from tests.qt_analysis_helpers import analysis, sites + + +@pytest.mark.parametrize("parameter", ["backend", "handleReady"]) +@pytest.mark.parametrize("style", ["legacy", "function", "arrow"]) +def test_req_qml018_ac04_native_parameters_own_nested_provider_and_callback_lookup(tmp_path, parameter, style): + """Legacy native binders shadow providers/callbacks; explicit handlers retain their own formals.""" + body = "{ backend.service.ready.connect(handleReady) }" + handler = {"legacy": "onReady: " + body, "function": "function onReady(other) " + body, + "arrow": "onReady: (other) => " + body}[style] + qml = "import QtQml\nQtObject { function handleReady() {} property Connections sub: Connections { target: backend.service; " + handler + " } }" + native = NATIVE.replace("void ready();", "void ready(int " + parameter + ");") + result = analysis(tmp_path, sources(tmp_path, qml=qml, native=native)) + assert len(sites(result, "context_subscription")) == (1 if style == "legacy" else 2) + assert len(sites(result, "context_handler_endpoint")) == (1 if style == "legacy" else 2) + if style == "legacy": + assert {qt_metadata(node)["subscription_form"] for node in sites(result, "context_subscription")} == {"Connections"} + + +def test_req_qml018_ac04_nearer_local_callback_keeps_explicit_authority(tmp_path): + """A local JS function inside a legacy handler precedes its same-name native parameter.""" + qml = "import QtQml\nQtObject { property Connections sub: Connections { target: backend.service; onReady: { function handleReady() {} backend.service.ready.connect(handleReady) } } }" + native = NATIVE.replace("void ready();", "void ready(int handleReady);") + result = analysis(tmp_path, sources(tmp_path, qml=qml, native=native)) + assert len(sites(result, "context_subscription")) == 2 + nested = next(node for node in sites(result, "context_subscription") if qt_metadata(node)["subscription_form"] == "signal.connect") + by_id = {node["id"]: node for node in result["nodes"]} + assert qml_metadata(by_id[qt_metadata(nested)["handler_target_id"]])["kind"] == "js_function" diff --git a/tests/test_qt_cpp_adoption_syntax.py b/tests/test_qt_cpp_adoption_syntax.py new file mode 100644 index 0000000000..bb571453a1 --- /dev/null +++ b/tests/test_qt_cpp_adoption_syntax.py @@ -0,0 +1,189 @@ +"""REQ-QML-018-AC02 valid Qt/C++ recovery keeps original bytes and safety failures.""" +from __future__ import annotations + +import pytest + +from graphify.extract import extract, extract_cpp +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_syntax import QtCppError, normalize_qt_cpp, read_cpp, source_span, walk + + +def write(root, source): + path = root / "adapter.cpp" + path.write_bytes(source) + return path + + +def facade(root, path): + result = extract([path], root=root, cache_root=root / ".probe-cache", parallel=False) + assert not result["failed_sources"] and not result["qt_failures"] and not result["qml_failures"] + return result + + +@pytest.mark.parametrize("parameter", [ + b"const Value &value = {}", b"Value value = { /* empty */ }", b"Value value = {\r\n}", +]) +def test_req_qml018_ac02_empty_parameter_default_recovers_exact_declared_member(tmp_path, parameter): + """A valid empty default has no evaluated expression to erase or fabricate.""" + source = b"class Value {}; class Adapter : public QObject { Q_OBJECT public: void accept(" + parameter + b"); };" + path = write(tmp_path, source) + unit = read_cpp(path, tmp_path) + assert not unit.root.has_error and unit.source == source + assert len(unit.parsed_source) == len(source) + assert unit.parsed_source.count(b"\r\n") == source.count(b"\r\n") + result = facade(tmp_path, path) + members = [qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("raw_name") == "accept" + and qt_metadata(node).get("kind") == "member"] + assert len(members) == 1 and members[0]["generic_target_id"] + assert members[0]["parameter_names"] == ["value"] + assert members[0]["parameter_types"] == (["const Value&"] if parameter.startswith(b"const") else ["Value"]) + span = members[0]["span"] + assert source[span["start_byte"]:span["end_byte"]] == b"void accept(" + parameter + b");" + assert not extract_cpp(path).get("parse_errors") + + +@pytest.mark.parametrize("semicolon", [b"", b";"]) +def test_req_qml018_ac02_unused_arguments_keep_evaluated_nested_calls(tmp_path, semicolon): + """Qt's supplied semicolon is parser syntax; argument calls remain real calls.""" + source = (b"int helper(){ return 1; } int second(){ return 2; }\n" + b"class Adapter : public QObject { Q_OBJECT public:\n" + b" void accept(int value) {\n Q_UNUSED(helper())" + semicolon + b"\n" + b" if (value) Q_UNUSED((second(), value))" + semicolon + b"\n }\n};\n") + path = write(tmp_path, source) + unit = read_cpp(path, tmp_path) + assert unit.source == source and len(unit.parsed_source) == len(source) + calls = [node for node in walk(unit.root) if node.type == "call_expression"] + assert len(calls) == 2 + assert {unit.field(node, "function") for node in calls} == {"helper", "second"} + assert {unit.source[node.start_byte:node.end_byte] for node in calls} == {b"helper()", b"second()"} + result = facade(tmp_path, path) + by_label = {node["label"]: node["id"] for node in result["nodes"]} + member = next(qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "member" + and qt_metadata(node).get("raw_name") == "accept") + retained = [edge for edge in result["edges"] if edge["relation"] == "calls" + and edge["source"] == member["generic_target_id"] + and edge["target"] in {by_label["helper()"], by_label["second()"]}] + assert {edge["source_location"] for edge in retained} == {"L4", "L5"} + assert not any(edge.get("context") == "qt_signal_emit" for edge in result["edges"]) + assert all(node.get("label") != "Q_UNUSED()" for node in result["nodes"]) + direct = extract_cpp(path) + assert not direct.get("parse_errors") + assert not any(call["callee"] == "Q_UNUSED" for call in direct.get("raw_calls", [])) + + +def test_req_qml018_ac02_bom_unicode_crlf_preserve_original_member_and_call_offsets(tmp_path): + """Recovery changes only syntax bytes; BOM, Unicode and CRLF remain in place.""" + source = (b"\xef\xbb\xbf" + "// café ☃\r\n".encode() + + b"int helper(){ return 1; }\r\nclass Value {};\r\n" + b"class Adapter : public QObject { Q_OBJECT public:\r\n" + b" void accept(const Value &value = {}) {\r\n Q_UNUSED(helper())\r\n }\r\n};\r\n") + path = write(tmp_path, source) + unit = read_cpp(path, tmp_path) + assert unit.source == source and unit.parsed_source.startswith(b"\xef\xbb\xbf") + assert [offset for offset, byte in enumerate(source) if byte in (10, 13)] == [ + offset for offset, byte in enumerate(unit.parsed_source) if byte in (10, 13) + ] + call = next(node for node in walk(unit.root) if node.type == "call_expression") + offset = source.index(b"helper()", source.index(b"Q_UNUSED")) + assert source_span(source, call.start_byte, call.end_byte) == source_span(source, offset, offset + 8) + assert (call.start_point.row, call.start_point.column) == (5, 10) + result = facade(tmp_path, path) + member = next(qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("raw_name") == "accept") + assert member["span"]["start_row"] == 4 + assert source[member["span"]["start_byte"]:member["span"]["end_byte"]].startswith(b"void accept(const Value &value = {})") + + +@pytest.mark.parametrize("argument", [b'""', b'"Q_UNUSED(fake())"', b'R"tag(x)tag"', b"1"]) +def test_req_qml018_ac02_literal_unused_argument_is_a_real_expression(tmp_path, argument): + """Quoted/raw literals are expression content, while their text stays inert.""" + source = b"void use(){ Q_UNUSED(" + argument + b") }" + unit = read_cpp(write(tmp_path, source), tmp_path) + assert unit.source == source and len(unit.parsed_source) == len(source) + assert argument in unit.parsed_source and not unit.root.has_error + assert not any(node.type == "call_expression" for node in walk(unit.root)) + result = facade(tmp_path, unit.path) + assert not any(edge["relation"] == "calls" for edge in result["edges"]) + + +@pytest.mark.parametrize("argument", [b"", b"/* no expression */"]) +def test_req_qml018_ac02_empty_or_comment_only_unused_argument_is_not_recovered(tmp_path, argument): + """A missing argument cannot become an invented cast value or valid statement.""" + source = b"void use(){ Q_UNUSED(" + argument + b") }" + assert normalize_qt_cpp(source) is None + with pytest.raises(QtCppError) as failure: + read_cpp(write(tmp_path, source), tmp_path) + assert failure.value.code == "QT_CPP_SYNTAX" + + +@pytest.mark.parametrize("source", [ + b"class A { void f(const T &value = {); };", + b"class A { void f(const T &value = {} ; };", + b"class A { void f(const T &value = {1}); };", + b"void f(int value) { Q_UNUSED(helper(value) }", + b"void f(int value) { Q_UNUSED(value) int broken = ; }", +]) +def test_req_qml018_ac02_damaged_or_unsupported_syntax_still_rejects(tmp_path, source): + """Recovering two valid forms must not hide unrelated or nonempty-default errors.""" + path = write(tmp_path, source) + with pytest.raises(QtCppError) as failure: + read_cpp(path, tmp_path) + assert failure.value.code == "QT_CPP_SYNTAX" + + +@pytest.mark.parametrize("source", [ + b'// Q_UNUSED(helper())\n/* const T &x = {} */\nconst char *s="Q_UNUSED(helper())";', + b'const char *s=R"tag(Q_UNUSED(helper()) const T &x = {})tag";', + b"#define BODY(value) \\\n Q_UNUSED(helper(value))\nclass A {};", + b"#define Q_UNUSED(value) value\nvoid f(int value){ Q_UNUSED(value); }", + b"#undef Q_UNUSED\nvoid f(int value){ Q_UNUSED(value); }", + b"void Q_UNUSED(int value) {} void f(){ Q_UNUSED(1); }", + b"void f(){ object.Q_UNUSED(1); other::Q_UNUSED(2); }", + b"int helper(); int f(){ return Q_UNUSED(helper()); }", + b"int helper(); void f(){ int value = Q_UNUSED(helper()); }", + b"void f(){ int value = {}; }", +]) +def test_req_qml018_ac02_inert_definitions_and_ordinary_contexts_are_not_masked(source): + """Lexical exclusion and expression-statement ownership guard false recovery.""" + assert normalize_qt_cpp(source) is None + + +def test_req_qml018_ac02_ordinary_cpp_default_and_named_function_keep_canonical_ids(tmp_path): + """The core C++ facade retains ordinary symbols and calls without Qt facts.""" + source = b"class Value {}; void accept(const Value &value = {}) {}\nvoid helper(){}\nvoid use(){helper();}\n" + path = write(tmp_path, source) + direct = extract_cpp(path) + assert not direct.get("parse_errors") + result = facade(tmp_path, path) + nodes = {node["label"]: node for node in result["nodes"]} + assert nodes["accept()"]["id"] == "adapter_accept" + assert nodes["helper()"]["id"] == "adapter_helper" and nodes["use()"]["id"] == "adapter_use" + assert not any(qt_metadata(node) for node in result["nodes"]) + assert any(edge["relation"] == "calls" and edge["source"] == "adapter_use" + and edge["target"] == "adapter_helper" for edge in result["edges"]) + + +@pytest.mark.parametrize("number", [b"16'384", b"0xAB'CD", b"0b10'01", b"1.2'34e1'0"]) +def test_req_qml018_ac02_numeric_separator_keeps_later_signal_ownership(tmp_path, number): + """Numeric apostrophes cannot mask the remaining class as a character literal.""" + source = (b"// caf\xc3\xa9\r\nclass Adapter : public QObject { Q_OBJECT public:\r\n" + b" static constexpr auto limit = " + number + b";\r\nsignals:\r\n" + b" void ready();\r\n};\r\n") + unit = read_cpp(write(tmp_path, source), tmp_path) + assert unit.source == source and number in unit.code + assert len(unit.code) == len(unit.parsed_source) == len(source) + result = facade(tmp_path, unit.path) + member = next(qt_metadata(node) for node in result["nodes"] + if qt_metadata(node).get("raw_name") == "ready") + assert member["roles"] == ["signal"] + span = member["span"] + assert source[span["start_byte"]:span["end_byte"]] == b"void ready();" + assert not extract_cpp(unit.path).get("parse_errors") + + +@pytest.mark.parametrize("number", [b"16''384", b"16'", b"0x'AB"]) +def test_req_qml018_ac02_malformed_numeric_separators_remain_rejected(tmp_path, number): + """Lexical number handling leaves the original token for parser validation.""" + source = b"class A : public QObject { Q_OBJECT public: int limit = " + number + b"; signals: void ready(); };" + with pytest.raises(QtCppError) as failure: + read_cpp(write(tmp_path, source), tmp_path) + assert failure.value.code == "QT_CPP_SYNTAX" diff --git a/tests/test_qt_cpp_context_ownership.py b/tests/test_qt_cpp_context_ownership.py new file mode 100644 index 0000000000..5f7efccde8 --- /dev/null +++ b/tests/test_qt_cpp_context_ownership.py @@ -0,0 +1,145 @@ +"""REQ-QML-008-AC02/016-AC01 retain native ownership over borrowed header facts.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp +from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt +from graphify.qt_qml_pipeline import resolve_qt_qml +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_cpp_definition_ownership import CPP, HEADER, canonical_run + + +def accepted_context(root, *, qualified, conflict=False): + """Use real finalized generic identities and unchanged accepted header overlays. + + Only implementation overlays are being refreshed. Generic canonical nodes + remain accepted inputs, including their exact header/definition provenance. + """ + cpp = CPP.replace("Backend::run", "Shared::Backend::run") if qualified else CPP + sources = {"backend.hpp": HEADER, "backend.cpp": cpp} + if conflict: + sources["competing.hpp"] = HEADER + complete = analysis(root, sources) + nodes = [node for node in complete["nodes"] + if not (node.get("source_file") == "backend.cpp" and qt_metadata(node))] + identities = {node["id"] for node in nodes} + edges = [edge for edge in complete["edges"] + if edge["source"] in identities and edge["target"] in identities] + return complete, nodes, edges + + +def refresh(root, nodes, edges): + """Exercise the same scratch overlay/join owner used by the extraction facade.""" + fresh = {"nodes": [], "edges": []} + added_nodes, added_edges = [], [] + failures = resolve_qt_qml([root / "backend.cpp"], [fresh], added_nodes, added_edges, + root=root, context_nodes=nodes, context_edges=edges) + assert not failures and not fresh.get("qml_failures") and not fresh.get("qt_failures") + return fresh, {"nodes": nodes + added_nodes, "edges": edges + added_edges} + + +@pytest.mark.parametrize("qualified", [True, False]) +def test_req_qml008_ac02_borrowed_complete_header_is_not_counted_as_two_definitions(tmp_path, qualified): + """Two representations of one source body keep the canonical method and real containment.""" + complete, nodes, edges = accepted_context(tmp_path, qualified=qualified) + method = canonical_run(complete) + before = copy.deepcopy((nodes, edges)) + fresh = {"nodes": [], "edges": []} + enrich_qt_cpp([tmp_path / "backend.cpp"], [fresh], root=tmp_path, + accepted_nodes=nodes, accepted_edges=edges) + assert not fresh.get("qml_failures") + member = sites(fresh, "member")[0] + metadata = qt_metadata(member) + assert metadata["class_name"] == "Shared::Backend" and metadata["class_id"] + assert metadata["generic_target_id"] == method["id"] + assert metadata["owner_id"] == metadata["class_id"] + assert any(edge["source"] == metadata["class_id"] and edge["target"] == member["id"] + and edge["relation"] == "contains" and edge["confidence"] == "EXTRACTED" + for edge in fresh["edges"]) + assert (nodes, edges) == before + + +@pytest.mark.parametrize("qualified", [True, False]) +def test_req_qml016_ac01_context_pipeline_preserves_owned_emission_through_json_reload(tmp_path, qualified): + """A refreshed implementation joins unchanged signal facts without rewriting borrowed data.""" + complete, nodes, edges = accepted_context(tmp_path, qualified=qualified) + method = canonical_run(complete) + before = copy.deepcopy((nodes, edges)) + fresh, assembled = refresh(tmp_path, nodes, edges) + member = sites(fresh, "member")[0] + member_metadata = qt_metadata(member) + assert member_metadata["class_id"] and member_metadata["generic_target_id"] == method["id"] + emission = sites(fresh, "emission")[0] + metadata = qt_metadata(emission) + assert metadata["owner_id"] == method["id"] and metadata["owner_class"] == "Shared::Backend" + assert metadata["status"] == "resolved" and metadata["target_id"] + span = metadata["span"] + original = (tmp_path / "backend.cpp").read_bytes() + assert original[span["start_byte"]:span["end_byte"]] == b"changed()" + graph = build_from_json(assembled, root=tmp_path) + assert graph.has_edge(member_metadata["class_id"], member["id"]) + assert graph.has_edge(method["id"], emission["id"]) + assert graph.has_edge(emission["id"], metadata["target_id"]) + assert not any(edge.get("relation") == "calls" for _, _, edge in graph.edges(emission["id"], data=True)) + path = tmp_path / "refreshed.json" + assert to_json(graph, {}, str(path)) + reloaded = build_from_json(json.loads(path.read_text(encoding="utf-8")), root=tmp_path) + assert reloaded.has_edge(method["id"], emission["id"]) + assert reloaded.has_edge(emission["id"], metadata["target_id"]) + assert reloaded.has_edge(member_metadata["class_id"], member["id"]) + assert qt_metadata(reloaded.nodes[emission["id"]])["owner_id"] == method["id"] + assert (nodes, edges) == before + + +def test_req_qml008_ac02_borrowed_distinct_complete_bodies_remain_ambiguous(tmp_path): + """Deduplication may join representations of one body, never two original source bodies.""" + _, nodes, edges = accepted_context(tmp_path, qualified=False, conflict=True) + classes = sites({"nodes": nodes}, "class") + assert len(classes) == 2 and all(qt_metadata(node)["is_definition"] is True for node in classes) + before = copy.deepcopy((nodes, edges)) + fresh, _ = refresh(tmp_path, nodes, edges) + member = sites(fresh, "member")[0] + emission = sites(fresh, "emission")[0] + assert qt_metadata(member)["class_id"] == "" + assert qt_metadata(emission)["status"] != "resolved" and qt_metadata(emission)["target_id"] == "" + assert (nodes, edges) == before + + +@pytest.mark.parametrize("body", [False, None]) +def test_req_qml008_ac02_borrowed_missing_body_authority_stays_unavailable(tmp_path, body): + """A forward/legacy metadata record cannot substitute for accepted complete-body authority.""" + _, nodes, edges = accepted_context(tmp_path, qualified=False) + for node in nodes: + if qt_metadata(node).get("kind") == "class": + update_qt(node, is_definition=body) + before = copy.deepcopy((nodes, edges)) + fresh, _ = refresh(tmp_path, nodes, edges) + assert qt_metadata(sites(fresh, "member")[0])["class_id"] == "" + emission = qt_metadata(sites(fresh, "emission")[0]) + assert emission["status"] == "unavailable" and emission["target_id"] == "" + assert not any(edge.get("context") == "qt_signal_emit" for edge in fresh["edges"]) + assert (nodes, edges) == before + + +@pytest.mark.parametrize("spelling", ["dot", "windows_separator", "absolute"]) +def test_req_qml008_ac02_borrowed_body_provenance_keeps_equivalent_path_representation(tmp_path, spelling): + """A valid alternate path representation cannot manufacture a second copy of one body.""" + complete, nodes, edges = accepted_context(tmp_path, qualified=False) + method = canonical_run(complete) + for node in nodes: + if qt_metadata(node).get("kind") == "class": + node["source_file"] = str(tmp_path / "backend.hpp") if spelling == "absolute" else ( + "./backend.hpp" if spelling == "dot" else ".\\backend.hpp") + before = copy.deepcopy((nodes, edges)) + fresh = {"nodes": [], "edges": []} + enrich_qt_cpp([tmp_path / "backend.cpp"], [fresh], root=tmp_path, + accepted_nodes=nodes, accepted_edges=edges) + member = qt_metadata(sites(fresh, "member")[0]) + assert member["class_id"] and member["generic_target_id"] == method["id"] + assert member["class_name"] == "Shared::Backend" and (nodes, edges) == before diff --git a/tests/test_qt_cpp_definition_ownership.py b/tests/test_qt_cpp_definition_ownership.py new file mode 100644 index 0000000000..243d8c8574 --- /dev/null +++ b/tests/test_qt_cpp_definition_ownership.py @@ -0,0 +1,266 @@ +"""REQ-QML-008-AC02/016/017 retain canonical implementation ownership.""" +import json +import re +import copy + +import pytest + +from graphify.build import build_from_json +from graphify.cluster import cluster +from graphify.export import to_html, to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_mapping import map_cpp +from graphify.extractors.qt_cpp_syntax import read_cpp +from tests.qt_analysis_helpers import analysis, sites + + +HEADER = '''namespace Shared { +class Backend : public QObject { + Q_OBJECT +signals: + void changed(); +public: + void run(); +}; +} +''' +CPP = '''#include "backend.hpp" +using namespace Shared; +void Backend::run() { + emit changed(); +} +''' +FORWARD = 'namespace Shared { class Backend; }\nQQmlApplicationEngine *admission_only;\n' + + +def canonical_run(result): + """The generic facade already records the header/implementation identity.""" + selected = [node for node in result["nodes"] if node.get("_callable") + and node.get("source_file") == "backend.hpp" and node.get("label") == ".run()"] + assert len(selected) == 1 + return selected[0] + + +def payload(path, key): + match = re.search(r"const " + key + r" = (\[.*?\]);", path.read_text(encoding="utf-8"), re.S) + assert match + return json.loads(match[1]) + + +@pytest.mark.parametrize("forward", [False, True]) +def test_req_qml008_ac02_definition_provenance_keeps_emission_owned_through_aggregate(tmp_path, forward): + """Using-namespace syntax must reuse the facade's exact accepted definition owner.""" + sources = {"backend.hpp": HEADER, "backend.cpp": CPP} + if forward: + sources["forward.hpp"] = FORWARD + result = analysis(tmp_path, sources) + method = canonical_run(result) + assert method["definition_file"] == "backend.cpp" and method["definition_location"] == "L3" + member = next(node for node in sites(result, "member") if node["source_file"] == "backend.cpp") + emission = sites(result, "emission")[0] + signal = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "changed") + assert qt_metadata(member)["generic_target_id"] == method["id"] + assert qt_metadata(member)["class_name"] == "Shared::Backend" + assert qt_metadata(member)["class_id"] + assert qt_metadata(emission)["owner_id"] == method["id"] + assert qt_metadata(emission)["status"] == "resolved" + assert qt_metadata(emission)["target_id"] == signal["id"] + source = CPP.encode() + span = qt_metadata(emission)["span"] + assert source[span["start_byte"]:span["end_byte"]] == b"changed()" + graph = build_from_json(result, root=tmp_path) + assert graph.has_edge(method["id"], emission["id"]) + assert graph.has_edge(emission["id"], signal["id"]) + assert not any(edge.get("relation") == "calls" for _, _, edge in graph.edges(emission["id"], data=True)) + groups = cluster(graph) + path = tmp_path / "graph.json" + assert to_json(graph, groups, str(path)) + reloaded = build_from_json(json.loads(path.read_text(encoding="utf-8")), root=tmp_path) + assert reloaded.has_edge(method["id"], emission["id"]) and reloaded.has_edge(emission["id"], signal["id"]) + html = tmp_path / "graph.html" + assert to_html(reloaded, groups, str(html), node_limit=2) + nodes, edges = payload(html, "RAW_NODES"), payload(html, "RAW_EDGES") + assert not [node for node in nodes if node["label"] in {"Qt member: run", "Qt emission: changed"} + and not any(node["id"] in (edge["from"], edge["to"]) for edge in edges)] + + +def test_req_qml016_ac01_forward_declaration_does_not_compete_with_complete_definition(tmp_path): + """A fully qualified control still rejects a forward declaration as a second body.""" + result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP.replace("Backend::run", "Shared::Backend::run"), + "forward.hpp": FORWARD}) + method = canonical_run(result) + emission = sites(result, "emission")[0] + assert qt_metadata(emission)["owner_id"] == method["id"] + assert qt_metadata(emission)["status"] == "resolved" + classes = sites(result, "class") + assert len(classes) == 2 + assert {node["source_file"]: qt_metadata(node)["is_definition"] for node in classes} == { + "backend.hpp": True, "forward.hpp": False} + + +def test_req_qml017_ac02_namespace_definition_owns_source_backed_qml_access(tmp_path): + """Loader and property sites keep the same proven canonical implementation owner.""" + cpp = CPP.replace(" emit changed();", ''' QQmlApplicationEngine engine; + engine.load("qrc:/App/Main.qml"); + QObject *root = engine.rootObjects().first(); + root->property("value"); + emit changed();''') + result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": cpp, + "Main.qml": "import QtQml\nQtObject { property int value: 1 }", + "app.qrc": 'Main.qml'}) + method = canonical_run(result) + owned = sites(result, "qml_load") + sites(result, "qml_root") + sites(result, "qml_access") + assert len(owned) == 3 + assert all(qt_metadata(node)["owner_id"] == method["id"] for node in owned) + assert all(qt_metadata(node)["status"] == "resolved" for node in owned) + graph = build_from_json(result, root=tmp_path) + assert all(graph.has_edge(method["id"], node["id"]) for node in owned) + # The owning method must survive the same publication/reload boundary as + # native event ownership, including reverse QML property access sites. + path = tmp_path / "graph.json" + assert to_json(graph, cluster(graph), str(path)) + reloaded = build_from_json(json.loads(path.read_text(encoding="utf-8")), root=tmp_path) + assert all(reloaded.has_edge(method["id"], node["id"]) for node in owned) + assert all(qt_metadata(reloaded.nodes[node["id"]])["owner_id"] == method["id"] for node in owned) + + +def mapped_run(root, nodes, edges): + """Reparse original source against the accepted boundary, including corrupt-proof controls.""" + mapping = map_cpp(read_cpp(root / "backend.cpp", root), nodes, edges, root=root) + mapping.bind_classes([]) + return next(record for record in mapping.functions if record["name"] == "run") + + +@pytest.mark.parametrize("spelling", ["absolute", "dot", "windows_separator"]) +def test_req_qml008_ac02_definition_paths_are_normalized_without_changing_source_spans(tmp_path, spelling): + """Equivalent accepted path representations still select the exact definition.""" + result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP}) + nodes = copy.deepcopy(result["nodes"]) + method = canonical_run({"nodes": nodes}) + method["definition_file"] = str(tmp_path / "backend.cpp") if spelling == "absolute" else ( + "./backend.cpp" if spelling == "dot" else ".\\backend.cpp") + before = copy.deepcopy((nodes, result["edges"])) + record = mapped_run(tmp_path, nodes, result["edges"]) + assert record["node_id"] == method["id"] and record["class_name"] == "Shared::Backend" + assert CPP.encode()[record["span"]["start_byte"]:record["span"]["end_byte"]] == b"void Backend::run() {\n emit changed();\n}" + assert (nodes, result["edges"]) == before + + +@pytest.mark.parametrize("corruption", ["wrong_file", "wrong_line", "not_callable", "inferred_owner"]) +def test_req_qml008_ac02_definition_recovery_rejects_incomplete_or_foreign_proof(tmp_path, corruption): + """No name-only replacement can compensate for rejected definition/ownership evidence.""" + result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP}) + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + method = canonical_run({"nodes": nodes}) + if corruption == "wrong_file": + method["definition_file"] = "other.cpp" + elif corruption == "wrong_line": + method["definition_location"] = "L2" + elif corruption == "not_callable": + method["_callable"] = False + else: + for edge in edges: + if edge["target"] == method["id"] and edge["relation"] == "method": + edge["confidence"] = "INFERRED" + before = copy.deepcopy((nodes, edges)) + record = mapped_run(tmp_path, nodes, edges) + assert record["class_id"] == "" and record["class_name"] == "Backend" + assert (nodes, edges) == before + + +def test_req_qml008_ac02_conflicting_callable_identity_is_not_arbitrarily_selected(tmp_path): + """Two accepted callables claiming the same definition remain explicitly ambiguous.""" + result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP}) + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + method = canonical_run({"nodes": nodes}) + conflicting = {**method, "id": "conflicting_canonical_callable"} + nodes.append(conflicting) + edges.extend({**edge, "target": conflicting["id"]} for edge in list(edges) + if edge["target"] == method["id"] and edge["relation"] == "method") + record = mapped_run(tmp_path, nodes, edges) + assert record["node_id"] == "" and record["status"] == "ambiguous" + assert set(record["candidates"]) == {method["id"], conflicting["id"]} + + +def test_req_qml008_ac02_multiple_real_class_bodies_are_not_forward_declarations(tmp_path): + """Genuine competing complete definitions cannot acquire an arbitrary native target.""" + result = analysis(tmp_path, {"backend.hpp": HEADER, "duplicate.hpp": HEADER, "backend.cpp": CPP}) + classes = sites(result, "class") + assert len(classes) == 2 and all(qt_metadata(node)["is_definition"] is True for node in classes) + implementation = next(node for node in sites(result, "member") if node["source_file"] == "backend.cpp") + assert qt_metadata(implementation)["class_id"] == "" + emission = sites(result, "emission")[0] + assert qt_metadata(emission)["status"] != "resolved" and qt_metadata(emission)["target_id"] == "" + + +@pytest.mark.parametrize("body", [False, None]) +def test_req_qml008_ac02_missing_body_proof_cannot_become_a_native_provider(tmp_path, body): + """Literal registration tolerates a forward declaration, but not absent definition proof.""" + from graphify.extractors.qt_cpp_facts import update_qt + from graphify.qt_event_index import QtEventIndex + from graphify.qt_qml_bridge import build_qt_qml_bridge + + registration = 'void install(){qmlRegisterType("Demo",1,0,"Backend");}' + result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP, + "forward.hpp": FORWARD, "register.cpp": registration}) + providers = sites(result, "registration") + assert len(providers) == 1 and qt_metadata(providers[0])["status"] == "resolved" + baseline = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path) + assert baseline.module_type("Demo", 1, 0, "Backend").status == "resolved" + nodes = copy.deepcopy(result["nodes"]) + for node in nodes: + if qt_metadata(node).get("kind") == "class" and node["source_file"] == "backend.hpp": + update_qt(node, is_definition=body) + assert QtEventIndex(nodes).class_name("Shared::Backend") == "" + unsupported = build_qt_qml_bridge(nodes, result["edges"], root=tmp_path) + assert unsupported.module_type("Demo", 1, 0, "Backend").status == "unavailable" + assert unsupported.unresolved[providers[0]["id"]]["reason"] == "native_class_definition_unavailable" + + +def test_req_qml008_ac02_external_and_function_local_classes_keep_unknown_ownership(tmp_path): + """Proven source containment does not invent an unavailable native class owner.""" + source = '#include "external.hpp"\nvoid External::run(){QObject *object;}\n' + ( + 'void utility(){struct Local {void work(){}}; QObject *object;}') + result = analysis(tmp_path, {"unresolved.cpp": source}) + external = next(node for node in sites(result, "member") if qt_metadata(node)["class_name"] == "External") + local = next(node for node in sites(result, "class") if qt_metadata(node)["raw_name"] == "Local") + assert qt_metadata(external)["class_id"] == "" and qt_metadata(external)["generic_target_id"] + assert qt_metadata(local)["class_id"] == "" and qt_metadata(local)["is_definition"] is True + function = next(node for node in result["nodes"] if node.get("label") == "utility()") + for node, owner in ((external, qt_metadata(external)["generic_target_id"]), (local, function["id"])): + assert qt_metadata(node)["owner_id"] == owner + assert any(edge["source"] == owner and edge["target"] == node["id"] + and edge["relation"] == "contains" and edge["confidence"] == "EXTRACTED" + and edge["context"] == "qt_source_site" for edge in result["edges"]) + assert qt_metadata(local)["generic_target_id"] == "" and qt_metadata(local)["status"] == "unavailable" + + +def test_req_qml008_ac02_unrelated_plain_cpp_keeps_generic_identities(tmp_path): + """The Qt overlay correction does not change admission or ordinary C++ calls.""" + source = "int helper(){return 1;}\nint use(){return helper();}" + result = analysis(tmp_path, {"plain.cpp": source}) + assert not any(qt_metadata(node) for node in result["nodes"]) + functions = {node["label"]: node["id"] for node in result["nodes"] if node.get("_callable")} + assert set(functions) == {"helper()", "use()"} + assert any(edge["source"] == functions["use()"] and edge["target"] == functions["helper()"] + and edge["relation"] == "calls" for edge in result["edges"]) + + +def test_req_qml008_ac02_definition_and_emission_spans_use_original_bom_crlf_unicode_bytes(tmp_path): + """Recovery changes ownership only; original encoded source still owns every span.""" + cpp = ("\ufeff// caf\u00e9 \u96ea\n" + CPP).replace("\n", "\r\n") + result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": cpp, "forward.hpp": FORWARD}) + method = canonical_run(result) + assert method["definition_file"] == "backend.cpp" and method["definition_location"] == "L4" + record = mapped_run(tmp_path, result["nodes"], result["edges"]) + assert record["node_id"] == method["id"] and record["class_name"] == "Shared::Backend" + original = (tmp_path / "backend.cpp").read_bytes() + assert original == cpp.encode("utf-8") + definition = record["span"] + assert original[definition["start_byte"]:definition["end_byte"]] == ( + b"void Backend::run() {\r\n emit changed();\r\n}") + emission = sites(result, "emission")[0] + span = qt_metadata(emission)["span"] + assert original[span["start_byte"]:span["end_byte"]] == b"changed()" + assert qt_metadata(emission)["owner_id"] == method["id"] + assert qt_metadata(emission)["status"] == "resolved" diff --git a/tests/test_qt_cpp_exposure.py b/tests/test_qt_cpp_exposure.py new file mode 100644 index 0000000000..a641f8122c --- /dev/null +++ b/tests/test_qt_cpp_exposure.py @@ -0,0 +1,139 @@ +"""Qt source declarations, canonical merges and source-only registration guards.""" +from __future__ import annotations + +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_cpp_test_helpers import HEADER, collect, facts + + +def test_members_properties_and_notify_have_exact_original_evidence(tmp_path): + result = collect(tmp_path, {"backend.hpp": HEADER}) + class_node = facts(result, "class", "Backend")[0] + class_md = qt_metadata(class_node) + assert class_md["source_q_object"] and class_md["is_qobject"] + assert class_md["generic_target_id"] in {node["id"] for node in result["nodes"]} + members = {qt_metadata(node)["raw_name"]: qt_metadata(node) for node in facts(result, "member")} + assert {name: md["roles"] for name, md in members.items()} == { + "value": [], "next": ["invokable"], "ordinary": [], "valueChanged": ["signal"], + "setValue": ["slot"], "hidden": ["invokable"]} + assert members["valueChanged"]["signature"] == "valueChanged(int)" + assert members["valueChanged"]["parameter_names"] == ["value"] + assert members["hidden"]["access"] == "private" + prop = qt_metadata(facts(result, "property", "value")[0]) + assert {key: prop[key] for key in ("raw_type", "read", "write", "notify")} == { + "raw_type": "int", "read": "value", "write": "setValue", "notify": "valueChanged"} + source = HEADER.encode() + assert source[prop["span"]["start_byte"]:prop["span"]["end_byte"]] == b"Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged)" + links = [qt_metadata(node) for node in facts(result, "property_accessor")] + assert {link["accessor_role"]: link["status"] for link in links} == {"read": "resolved", "write": "resolved", "notify": "resolved"} + assert all(link["generic_target_id"] in {node["id"] for node in result["nodes"]} for link in links) + + +def test_header_implementation_members_reuse_accepted_canonical_ids(tmp_path): + source = '#include "backend.hpp"\nint Backend::value() const { return 1; }\nint Backend::next(int amount) { return amount; }' + result = collect(tmp_path, {"backend.hpp": HEADER, "backend.cpp": source}) + for name in ("value", "next"): + declarations = [qt_metadata(node) for node in facts(result, "member", name)] + assert len(declarations) == 2 + assert len({item["generic_target_id"] for item in declarations}) == 1 + assert all(item["generic_target_id"] and item["class_id"] for item in declarations) + assert not any(result.get("qml_failures") for result in result["per_file"]) + + +def test_notify_same_name_ordinary_function_is_not_signal(tmp_path): + source = '''class B: public QObject { Q_OBJECT Q_PROPERTY(int value READ value NOTIFY changed) +public: int value(); void changed(int value); };''' + result = collect(tmp_path, {"b.hpp": source}) + notify = next(qt_metadata(node) for node in facts(result, "property_accessor") if qt_metadata(node)["accessor_role"] == "notify") + assert notify["status"] == "unavailable" and notify["generic_target_id"] == "" + assert not any(edge.get("metadata", {}).get("qt", {}).get("accessor_role") == "notify" for edge in result["edges"]) + + +def test_identifier_named_element_flags_and_quoted_rejection(tmp_path): + source = '''class State: public QObject { Q_OBJECT QML_NAMED_ELEMENT(State) QML_SINGLETON QML_UNCREATABLE("Use singleton") }; +class Bad: public QObject { Q_OBJECT QML_NAMED_ELEMENT("Bad") }; +class Hidden: public QObject { Q_OBJECT QML_ANONYMOUS };''' + result = collect(tmp_path, {"types.hpp": source}) + registrations = [qt_metadata(node) for node in facts(result, "registration")] + state = next(md for md in registrations if md["class_name"] == "State") + assert state["raw_name"] == "State" and state["singleton"] and not state["creatable"] + bad = next(md for md in registrations if md["class_name"] == "Bad") + assert bad["status"] == "unsupported" and bad["reason"] == "named_element_requires_identifier" + hidden = next(md for md in registrations if md["class_name"] == "Hidden") + assert hidden["anonymous"] and not hidden["raw_name"] and not hidden["creatable"] + + +def test_literal_registration_variants_exact_versions_and_class_mapping(tmp_path): + calls = '''void install(Backend *instance) { +qmlRegisterType("Demo.Types",1,2,"Backend"); +qmlRegisterUncreatableType("Demo.Types",1,2,"ReadOnly","reason"); +qmlRegisterSingletonType("Demo.State",2,0,"State",factory); +qmlRegisterSingletonInstance("Demo.State",2,0,"Instance",instance); +qmlRegisterAnonymousType("Demo.Types",1); +}''' + result = collect(tmp_path, {"backend.hpp": HEADER, "register.cpp": calls}) + literal = [qt_metadata(node) for node in facts(result, "registration") if not qt_metadata(node)["module_required"]] + assert len(literal) == 5 and all(md["status"] == "resolved" for md in literal) + assert len({md["class_id"] for md in literal}) == 1 + assert {(md["uri"], md["major"], md["minor"], md["raw_name"]) for md in literal} == { + ("Demo.Types", 1, 2, "Backend"), ("Demo.Types", 1, 2, "ReadOnly"), + ("Demo.State", 2, 0, "State"), ("Demo.State", 2, 0, "Instance"), ("Demo.Types", 1, 0, "")} + + +def test_dynamic_unsupported_and_missing_arguments_do_not_become_providers(tmp_path): + calls = '''void install() { +qmlRegisterType(uri,1,0,"Dynamic"); +if (enabled) { qmlRegisterType("Demo",1,0,"Conditional"); } +qmlRegisterSingletonType("Demo",1,0,"MissingCallback"); +qmlRegisterUnknown("Demo",1,0,"Unknown"); +qmlRegisterType("Demo",1,0,"Missing"); +}''' + result = collect(tmp_path, {"backend.hpp": HEADER, "register.cpp": calls}) + literal = [qt_metadata(node) for node in facts(result, "registration") if not qt_metadata(node)["module_required"]] + assert len(literal) == 5 and all(md["status"] != "resolved" for md in literal) + assert any(md["reason"] == "conditional_registration" for md in literal) + assert any(md["reason"] == "registration_mechanism_unsupported" for md in literal) + assert any(md["reason"] == "class_not_in_corpus" for md in literal) + + +def test_foreign_wrapper_is_explicit_and_nested_macros_do_not_expose_outer(tmp_path): + source = '''class Outer: public QObject { Q_OBJECT public: +class Inner: public QObject { Q_OBJECT QML_ELEMENT }; }; +class Wrapper: public QObject { Q_OBJECT QML_NAMED_ELEMENT(Foreign) QML_FOREIGN(Outer) };''' + result = collect(tmp_path, {"nested.hpp": source}) + declarations = [qt_metadata(node) for node in facts(result, "registration")] + assert {md["class_name"] for md in declarations} == {"Outer::Inner", "Wrapper"} + wrapper = next(md for md in declarations if md["class_name"] == "Wrapper") + assert wrapper["status"] == "unsupported" and wrapper["unsupported_macros"] == ["QML_FOREIGN"] + + +def test_conditional_macro_and_property_remain_uncertain(tmp_path): + source = '''class Backend: public QObject { +Q_OBJECT +#if ENABLE_EXPOSURE +QML_ELEMENT +Q_PROPERTY(int value READ value) +#endif +public: int value(); +};''' + result = collect(tmp_path, {"backend.hpp": source}) + registration = qt_metadata(facts(result, "registration")[0]) + assert registration["status"] == "dynamic" and registration["reason"] == "conditional_exposure" + prop = qt_metadata(facts(result, "property")[0]) + assert prop["status"] == "dynamic" and prop["reason"] == "conditional_property_declaration" + + +def test_additional_unsupported_macro_has_original_annotation_evidence(tmp_path): + result = collect(tmp_path, {"backend.hpp": HEADER.replace("QML_NAMED_ELEMENT(Backend)", "QML_NAMED_ELEMENT(Backend) QML_EXTRA_VERSION(2, 0)")}) + md = qt_metadata(facts(result, "registration")[0]) + assert md["status"] == "unsupported" and md["unsupported_macros"] == ["QML_EXTRA_VERSION"] + macro = next(item for item in md["annotations"] if item["name"] == "QML_EXTRA_VERSION") + assert macro["args"] == ["2", "0"] + source = (tmp_path / "backend.hpp").read_bytes() + assert source[macro["span"]["start_byte"]:macro["span"]["end_byte"]] == b"QML_EXTRA_VERSION(2, 0)" + + +def test_pointer_property_name_and_type_remain_separate(tmp_path): + source = 'class B: public QObject { Q_OBJECT Q_PROPERTY(QObject *child READ child) public: QObject *child(); };' + result = collect(tmp_path, {"b.hpp": source}) + prop = qt_metadata(facts(result, "property", "child")[0]) + assert prop["raw_type"] == "QObject*" and prop["status"] == "resolved" and prop["read"] == "child" diff --git a/tests/test_qt_cpp_file_role_updates.py b/tests/test_qt_cpp_file_role_updates.py new file mode 100644 index 0000000000..296b54350b --- /dev/null +++ b/tests/test_qt_cpp_file_role_updates.py @@ -0,0 +1,258 @@ +"""INC-QML-33/35 retain file roles and retire only stale generated import endpoints.""" +from __future__ import annotations + +import json +import os +import stat + +import pytest + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.test_qt_cpp_file_roles import cpp_fixture +from tests.test_qt_final_incremental_parity import clean, normalized, run + + +def cpp_membership(graph): + """The C++ endpoint is its original generic file ID, never a guessed class.""" + return next(qml_metadata(node) for _, node in graph.nodes(data=True) + if qml_metadata(node).get("kind") == "membership_resolution" + and qml_metadata(node).get("source_kind") == "cpp") + + +def assert_file_context(graph): + target = cpp_membership(graph)["target_id"] + assert target and graph.nodes[target]["source_file"] == "backend.cpp" + unknown = [identity for identity, node in graph.nodes(data=True) + if qt_metadata(node).get("kind") == "emission" and not qt_metadata(node).get("owner_id")] + assert unknown and all(graph.has_edge(target, identity) for identity in unknown) + assert all(any(endpoint == identity and data.get("context") == "qt_source_file" + and data["relation"] == "contains" and data["confidence"] == "EXTRACTED" + for _, endpoint, data in graph.edges(target, data=True)) for identity in unknown) + return target + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml020_ac03_cpp_transport_edits_and_removal_preserve_exact_membership(tmp_path, monkeypatch, operation): + """Cold/warm/manual/watch/JSON products retain file roles as native type proof changes.""" + cpp_fixture(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + cold = clean(tmp_path, tmp_path / ".cold-cache") + assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold) + initial = run(tmp_path, monkeypatch, operation) + assert normalized(initial) == normalized(cold) + file_id = assert_file_context(initial) + path = tmp_path / "backend.cpp" + original = path.read_bytes() + + # Namespace-import type authority is conservatively incomplete, but the + # accepted file still owns source context and explicit build membership. + path.write_bytes(original.replace(b"using QObject = int;", b"using namespace Hidden;")) + changed = run(tmp_path, monkeypatch, operation, [path]) + assert assert_file_context(changed) == file_id + assert changed.nodes[file_id]["metadata"]["cpp_constructor_types"]["complete"] is False + assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".changed-cache")) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(changed) + + # An unavailable source cannot keep its unchanged module declaration's + # previous endpoint; restoration produces the same accepted file identity. + path.unlink() + removed = run(tmp_path, monkeypatch, operation, [path]) + assert file_id not in removed + assert "missing" not in removed, "Deleted C++ source left its generated include endpoint" + assert cpp_membership(removed)["status"] == "unavailable" + assert not cpp_membership(removed)["target_id"] + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache")) + path.write_bytes(original) + restored = run(tmp_path, monkeypatch, operation, [path]) + assert assert_file_context(restored) == file_id + assert "missing" in restored + assert normalized(restored) == normalized(initial) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(restored) + + +def products(root): + return {name: (root / "graphify-out" / name).read_bytes() + for name in ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")} + + +def has_import(graph, source): + return any(data.get("relation") == "imports" and data.get("source_file") == source + and data.get("_origin") == "ast" + for _, _, data in graph.edges("missing", data=True)) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac04_cpp_deleted_import_keeps_still_referenced_external_endpoint(tmp_path, monkeypatch, operation): + """A second real import protects the shared endpoint until its final source reference disappears.""" + cpp_fixture(tmp_path) + keeper = tmp_path / "keeper.cpp" + original = b'#include "missing.hpp"\nint keep() { return 1; }\n' + keeper.write_bytes(original) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + assert has_import(initial, "backend.cpp") and has_import(initial, "keeper.cpp") + assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".initial-cache")) + source = tmp_path / "backend.cpp" + backend = source.read_bytes() + source.unlink() + removed = run(tmp_path, monkeypatch, operation, [source]) + assert "missing" in removed and has_import(removed, "keeper.cpp") + assert not has_import(removed, "backend.cpp") + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache")) + + # Removing the final import is a real C++ edit, independent of metadata + # membership and the earlier source deletion. Re-addition restores its ID. + keeper.write_bytes(original.split(b"\n", 1)[1]) + edited = run(tmp_path, monkeypatch, operation, [keeper]) + assert "missing" not in edited + assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited-cache")) + keeper.write_bytes(original) + restored = run(tmp_path, monkeypatch, operation, [keeper]) + assert "missing" in restored and has_import(restored, "keeper.cpp") + assert normalized(restored) == normalized(removed) + source.write_bytes(backend) + restored = run(tmp_path, monkeypatch, operation, [source]) + assert normalized(restored) == normalized(initial) + before = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(restored) + assert products(tmp_path) == before + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary") +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac06_cpp_orphan_cleanup_failed_publication_preserves_products_and_retries(tmp_path, monkeypatch, operation): + """Real graph-write rejection preserves old products/cache; deletion retry retires the orphan.""" + from graphify.watch import _rebuild_code + from graphify.cache import cache_dir + from tests.test_qt_final_incremental_parity import published + from tests.test_qt_readonly_publication import durable + + cpp_fixture(tmp_path) + # Qt cohort sources deliberately bypass individual AST reuse. An unrelated + # real Python source supplies old cache bytes that rejected publication must + # preserve, rather than fabricating an entry or claiming an empty cache. + (tmp_path / "keep.py").write_bytes(b"def untouched(): return 7\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + assert "missing" in initial + accepted = durable(tmp_path) + cache = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")} + assert cache + source = tmp_path / "backend.cpp" + original = source.read_bytes() + source.unlink() + target = tmp_path / "graphify-out/graph.json" + target.chmod(stat.S_IREAD) + try: + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, monkeypatch, operation, [source]) + assert rejected.value.code == 1 + else: + assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + assert durable(tmp_path) == accepted + assert all(path.exists() and path.read_bytes() == data for path, data in cache.items()) + assert normalized(published(tmp_path)) == normalized(initial) + assert not target.stat().st_mode & stat.S_IWRITE + assert not list(target.parent.glob(".gfy-*.tmp")) + finally: + target.chmod(stat.S_IWRITE) + repaired = run(tmp_path, monkeypatch, operation, [source]) + assert "missing" not in repaired + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired-cache")) + accepted = durable(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(repaired) + assert durable(tmp_path) == accepted + source.write_bytes(original) + assert normalized(run(tmp_path, monkeypatch, operation, [source])) == normalized(initial) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac04_cpp_import_cleanup_preserves_same_label_source_and_semantic_roles(tmp_path, monkeypatch, operation): + """Exact endpoint identity controls cleanup; equal display labels do not erase unrelated facts.""" + cpp_fixture(tmp_path) + (tmp_path / "keeper.cpp").write_bytes(b"class missing {};\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + source_owned = next(identity for identity, node in initial.nodes(data=True) + if node.get("label") == "missing" and node.get("source_file") == "keeper.cpp") + output = tmp_path / "graphify-out/graph.json" + raw = json.loads(output.read_text(encoding="utf-8")) + retained = [{"id": "authored_missing", "label": "missing", "type": "external", "external": True, + "file_type": "concept", "source_file": "", "_origin": "semantic"}, + {"id": "manual_missing", "label": "missing", "type": "concept", + "file_type": "concept", "source_file": ""}] + raw["nodes"].extend(retained) + output.write_text(json.dumps(raw), encoding="utf-8") + source = tmp_path / "backend.cpp" + source.unlink() + removed = run(tmp_path, monkeypatch, operation, [source]) + assert "missing" not in removed + assert {"authored_missing", "manual_missing", source_owned} <= set(removed) + assert removed.nodes["authored_missing"]["_origin"] == "semantic" + expected_nodes, expected_edges = normalized(clean(tmp_path, tmp_path / ".removed-cache")) + actual_nodes, actual_edges = normalized(removed) + assert set(actual_nodes) == set(expected_nodes) | {node["id"] for node in retained} + assert all(actual_nodes[identity] == data for identity, data in expected_nodes.items()) + assert actual_edges == expected_edges + before = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed) + assert products(tmp_path) == before + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("corruption", ["semantic_origin", "unmarked_origin", "foreign_source", + "conflicting_source", "context", "confidence", "opposite_direction", + "metadata", "metadata_empty", "foreign_origin", "ast_origin", + "callable", "type", "source", "label", + "partial_direction", "foreign_direction"]) +def test_req_qml011_ac04_cpp_import_cleanup_requires_exact_prior_source_proof(tmp_path, monkeypatch, operation, corruption): + """Retired unproved imports cannot authorize cleanup, even when the remaining candidate is valid.""" + cpp_fixture(tmp_path) + (tmp_path / "keeper.cpp").write_bytes(b"int retained() { return 7; }\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + run(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out/graph.json" + raw = json.loads(output.read_bytes()) + edge = next(edge for edge in raw["links"] if edge.get("relation") == "imports" + and edge.get("source_file") == "backend.cpp") + if corruption == "semantic_origin": + edge["_origin"] = "semantic" + elif corruption == "unmarked_origin": + edge.pop("_origin") + elif corruption in {"foreign_source", "conflicting_source"}: + edge["source_file"] = "foreign.cpp" if corruption == "foreign_source" else "keeper.cpp" + elif corruption in {"context", "confidence"}: + edge[corruption] = "unproved" + elif corruption == "opposite_direction": + edge["_src"], edge["_tgt"] = edge["target"], edge["source"] + elif corruption in {"metadata", "metadata_empty", "foreign_origin", "ast_origin", "callable", "type", "source", "label"}: + target = next(node for node in raw["nodes"] if node["id"] == "missing") + key, value = {"metadata": ("metadata", {"note": "authored context"}), + "metadata_empty": ("metadata", {}), "foreign_origin": ("_origin", "foreign"), + "ast_origin": ("_origin", "ast"), "callable": ("_callable", True), + "type": ("type", "class"), "source": ("source_file", "keeper.cpp"), + "label": ("label", "authored display")}[corruption] + target[key] = value + else: + edge["_src"] = edge["source"] if corruption == "partial_direction" else "foreign_endpoint" + if corruption == "foreign_direction": + edge["_tgt"] = edge["target"] + output.write_text(json.dumps(raw), encoding="utf-8") + source = tmp_path / "backend.cpp" + source.unlink() + removed = run(tmp_path, monkeypatch, operation, [source]) + assert "missing" in removed and removed.degree("missing") == 0 + expected_nodes, expected_edges = normalized(clean(tmp_path, tmp_path / ".removed-cache")) + actual_nodes, actual_edges = normalized(removed) + assert set(actual_nodes) == set(expected_nodes) | {"missing"} + assert all(actual_nodes[identity] == data for identity, data in expected_nodes.items()) + assert actual_edges == expected_edges + if corruption == "metadata": + assert removed.nodes["missing"]["metadata"] == {"note": "authored context"} diff --git a/tests/test_qt_cpp_file_roles.py b/tests/test_qt_cpp_file_roles.py new file mode 100644 index 0000000000..aa52f91431 --- /dev/null +++ b/tests/test_qt_cpp_file_roles.py @@ -0,0 +1,200 @@ +"""INC-QML-33 validates generic C++ file provenance at both Qt file joins.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_source_containment import attach_qt_file_sites +from tests.test_qt_project_membership import apply, facts, fixture + + +def cpp_fixture(root): + """Real C++ transport owns includes/shadows; an opaque constructor lacks an owner.""" + fixture(root) + (root / "backend.cpp").write_text( + '#include "missing.hpp"\nusing QObject = int;\n' + 'class Backend { public: Backend(Unknown count); };\n' + 'Backend::Backend(Unknown count) { emit ready(); }\n', + encoding="utf-8", newline="") + from graphify.extract import extract + paths = [root / name for name in ("backend.cpp", "CMakeLists.txt", "resources.qrc", "qml/ResizeHandle.qml")] + result = extract(paths, root=root, cache_root=root, parallel=False) + assert not result.get("qml_failures") + return result + + +def file_node(nodes): + return next(node for node in nodes if node.get("label") == "backend.cpp" + and node.get("source_file") == "backend.cpp" and not node.get("_callable_class")) + + +def project(root, nodes, edges, declarations, boundary, fresh_ids=()): + """Both production joins consume borrowed facts without mutating their dictionaries.""" + if boundary == "membership": + sites = apply(root, nodes, edges, declarations, fresh_ast_ids=fresh_ids) + assert len(sites) == 1 + md = qml_metadata(sites[0]) + return md["target_id"], md["reason"] + unknown = [node for node in nodes if qt_metadata(node).get("kind") == "emission" + and not qt_metadata(node).get("owner_id")] + assert unknown + active = {root / "backend.cpp": {"nodes": unknown, "edges": []}} + added = attach_qt_file_sites(active, nodes, [], root=root, fresh_ast_ids=fresh_ids) + assert all(edge["context"] == "qt_source_file" and edge["confidence"] == "EXTRACTED" for edge in added) + assert all(edge["source"] == edge["_src"] and edge["target"] == edge["_tgt"] for edge in added) + assert all(not qt_metadata(node)["owner_id"] for node in unknown) + return added[0]["source"] if added else "", "" + + +@pytest.mark.parametrize("boundary", ["membership", "containment"]) +@pytest.mark.parametrize("transport", ["producer", "incomplete", "legacy", "fresh", "reload"]) +def test_req_qml020_ac01_ac03_cpp_file_transport_retains_exact_file_identity(tmp_path, boundary, transport): + """Current/old/incomplete/fresh/reloaded file facts retain explicit source links.""" + result = cpp_fixture(tmp_path) + if transport == "reload": + output = tmp_path / "graph.json" + assert to_json(build_from_json(result, root=tmp_path), {}, str(output)) + result = json.loads(output.read_text(encoding="utf-8")) + nodes = copy.deepcopy(result["nodes"]) + edges = copy.deepcopy(result.get("edges", result.get("links", []))) + target = file_node(nodes) + if transport == "incomplete": + target["metadata"]["cpp_constructor_types"]["complete"] = False + elif transport == "legacy": + target.pop("metadata") + elif transport == "fresh": + target.pop("_origin") + declarations = [next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source" + and qml_metadata(node).get("source_kind") == "cpp")] + borrowed = list(nodes) + before = copy.deepcopy(borrowed) + target_id, reason = project(tmp_path, nodes, edges, declarations, boundary, + {target["id"]} if transport == "fresh" else ()) + assert target_id == target["id"] and not reason + assert borrowed == before + + +CORRUPTIONS = ["foreign", "outside_root", "duplicate", "callable", "class", "function_type", + "non_ast", "unmarked", "location", "label", "file_type", + "other_metadata", "record_scalar", "version", "bool_version", "complete", + "negative_size", "bool_size", "size_type", "includes_type", "includes_limit", + "include_encoding", "include_kind", "include_span", "include_position", + "include_bounds", "include_foreign", "shadows_type", "shadows_limit", + "shadow_encoding", "shadow_scope", "shadow_role", "shadow_position"] + + +def corrupt(nodes, target, corruption): + """Change actual producer transport, never a fake decoder or resolver result.""" + record = target["metadata"]["cpp_constructor_types"] + if corruption in {"foreign", "outside_root"}: + target["source_file"] = "foreign.cpp" if corruption == "foreign" else "../backend.cpp" + elif corruption == "duplicate": + nodes.append({**copy.deepcopy(target), "id": target["id"] + "_competing"}) + elif corruption in {"callable", "class"}: + target["_callable" if corruption == "callable" else "_callable_class"] = True + elif corruption == "function_type": + target["type"] = "function" + elif corruption in {"non_ast", "unmarked"}: + target["_origin"] = "semantic" if corruption == "non_ast" else None + elif corruption in {"location", "label", "file_type"}: + target[{"location": "source_location", "label": "label", "file_type": "file_type"}[corruption]] = "wrong" + elif corruption == "other_metadata": + target["metadata"]["semantic"] = {"kind": "function"} + elif corruption == "record_scalar": + target["metadata"]["cpp_constructor_types"] = [] + elif corruption in {"version", "bool_version", "complete", "negative_size", "bool_size", "size_type"}: + key, value = {"version": ("contract_version", 2), "bool_version": ("contract_version", True), + "complete": ("complete", 1), "negative_size": ("source_size", -1), + "bool_size": ("source_size", True), "size_type": ("source_size", "100")}[corruption] + record[key] = value + elif corruption.endswith("_type"): + record[corruption.removesuffix("_type")] = {} + elif corruption.endswith("_limit"): + record[corruption.removesuffix("_limit")] *= 51 + elif corruption.startswith("include_"): + item = record["includes"][0] + if corruption == "include_encoding": + item["literal_b64"] = "invalid!" + elif corruption == "include_kind": + item["kind"] = "dynamic" + elif corruption == "include_span": + item["span"]["end_row"] = item["span"]["start_row"] + item["span"]["end_column"] += 1 + elif corruption == "include_position": + item["end_byte"] += 1 + elif corruption == "include_bounds": + item["span"]["end_byte"] = record["source_size"] + 1 + else: + import base64 + item["literal_b64"] = base64.b64encode(b"/foreign.hpp").decode() + else: + item = record["shadows"][0] + if corruption in {"shadow_encoding", "shadow_scope"}: + item["scope_b64"] = "invalid!" if corruption == "shadow_encoding" else "YmFkLXNjb3Bl" + elif corruption == "shadow_role": + item["class_scope"] = 1 + else: + item["start_byte"] += 1 + + +@pytest.mark.parametrize("boundary", ["membership", "containment"]) +@pytest.mark.parametrize("corruption", CORRUPTIONS) +def test_req_qml020_ac02_cpp_file_transport_cannot_authorize_corrupted_file_role(tmp_path, boundary, corruption): + """Foreign/duplicate/semantic/ill-typed file evidence creates no guessed endpoint.""" + result = cpp_fixture(tmp_path) + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + target = file_node(nodes) + declarations = [next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source" + and qml_metadata(node).get("source_kind") == "cpp")] + corrupt(nodes, target, corruption) + borrowed, before = list(nodes), copy.deepcopy(nodes) + target_id, reason = project(tmp_path, nodes, edges, declarations, boundary) + assert not target_id + if boundary == "membership": + assert reason in {"membership_target_outside_accepted_corpus", "duplicate_provider"} + assert borrowed == before + + +@pytest.mark.parametrize("complete", [False, True]) +def test_req_qml008_ac02_cpp_file_role_preserves_separate_constructor_type_authority(tmp_path, complete): + """A valid file record can contain sites while incomplete type proof stays unusable.""" + from graphify.extractors.cpp_constructor_type_authority import ConstructorTypeAuthority, valid_source_transport + from graphify.qt_source_file_role import generic_file_role + + result = cpp_fixture(tmp_path) + target = file_node(result["nodes"]) + target["metadata"]["cpp_constructor_types"]["complete"] = complete + record = target["metadata"]["cpp_constructor_types"] + before = copy.deepcopy(target) + assert valid_source_transport(record) + assert generic_file_role(target, "backend.cpp") + authority = ConstructorTypeAuthority([target]) + assert bool(authority._fact("backend.cpp")) is complete + assert target == before + + +@pytest.mark.parametrize("boundary", ["membership", "containment"]) +def test_req_qml020_ac02_cpp_file_joins_read_no_source_and_execute_no_corpus(tmp_path, monkeypatch, boundary): + """After real extraction, both joins use accepted dictionaries only.""" + import builtins + import subprocess + from pathlib import Path + result = cpp_fixture(tmp_path) + declarations = [node for node in facts(result, "qt_source") if qml_metadata(node)["source_kind"] == "cpp"] + target = file_node(result["nodes"]) + + def forbidden(*_args, **_kwargs): + raise AssertionError("File identity must not read or execute corpus sources") + + for method in ("open", "read_bytes", "read_text", "glob", "rglob"): + monkeypatch.setattr(Path, method, forbidden) + monkeypatch.setattr(builtins, "open", forbidden) + monkeypatch.setattr(subprocess, "run", forbidden) + monkeypatch.setattr(subprocess, "Popen", forbidden) + assert project(tmp_path, result["nodes"], result["edges"], declarations, boundary)[0] == target["id"] diff --git a/tests/test_qt_cpp_owner_upgrade.py b/tests/test_qt_cpp_owner_upgrade.py new file mode 100644 index 0000000000..b480da16cb --- /dev/null +++ b/tests/test_qt_cpp_owner_upgrade.py @@ -0,0 +1,91 @@ +"""Native ownership policy upgrades refresh real unchanged-input CLI products.""" +from __future__ import annotations + +import pytest + +import graphify.cache as cache +import graphify.extract as extraction +import graphify.qt_incremental as policy +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_analysis_state import QT_STATE_FILE, inspect_qt_analysis +from tests.test_qt_cpp_upgrade_invalidation import cli, unchanged_python + + +SOURCES = { + "backend.hpp": "namespace Shared {\nclass Backend : public QObject {\n Q_OBJECT\n public: void run();\n signals: void changed();\n};\n}\n", + "backend.cpp": '#include "backend.hpp"\nusing namespace Shared;\nvoid Backend::run(){ emit changed(); }\n', + # This forward is Qt-admitted by its unrelated engine declaration. It must + # remain a fact without competing with the actual complete Backend class. + "forward.hpp": "namespace Shared { class Backend; }\nQQmlApplicationEngine *engine;\n", + "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n", +} + + +def prior_epoch(root, monkeypatch): + """Publish a real accepted graph/stamp under the previous policy only. + + Extraction and graph persistence are production code. The old policy stamp + models the upgrade boundary; mapping regressions separately reproduce the + original orphan facts before correction, without duplicating old analyzers. + """ + paths = [] + for name, content in SOURCES.items(): + path = root / name + path.write_text(content, encoding="utf-8") + paths.append(path) + with monkeypatch.context() as historical: + historical.setattr(policy, "QT_POLICY_VERSION", 2) + graph = cli(root, historical, "extract") + assert not inspect_qt_analysis(root, root / "graphify-out", paths).changed + return paths, graph + + +@pytest.mark.parametrize("operation", ["update", "extract"]) +def test_qml008_ac02_policy_three_reparses_unchanged_native_ownership(tmp_path, monkeypatch, operation): + """Old policy cannot skip parsing; current facts and unrelated Python survive reload.""" + monkeypatch.chdir(tmp_path) + paths, before = prior_epoch(tmp_path, monkeypatch) + sources = {path: path.read_bytes() for path in paths} + version = cache._EXTRACTOR_VERSION + assert inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed + observed, real = [], extraction._safe_extract_with_xaml_root + + def observe(extractor, path, root): + observed.append(path) + return real(extractor, path, root) + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe) + current = cli(tmp_path, monkeypatch, operation) + assert set(paths[:3]).issubset(observed) + assert not inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed + members = [(identity, qt_metadata(data)) for identity, data in current.nodes(data=True) + if qt_metadata(data).get("kind") == "member"] + emissions = [(identity, qt_metadata(data)) for identity, data in current.nodes(data=True) + if qt_metadata(data).get("kind") == "emission"] + assert members and emissions + assert all(metadata["class_id"] and current.degree(identity) for identity, metadata in members) + assert all(metadata["owner_id"] and metadata["status"] == "resolved" and current.degree(identity) + for identity, metadata in emissions) + assert unchanged_python(before) == unchanged_python(current) + assert sources == {path: path.read_bytes() for path in paths} and version == cache._EXTRACTOR_VERSION + stable = cli(tmp_path, monkeypatch, operation) + assert unchanged_python(stable) == unchanged_python(current) + assert {identity for identity, _ in stable.nodes(data=True)} == set(current) + + +@pytest.mark.parametrize("force", [False, True]) +def test_qml016_ac04_failed_owner_upgrade_retains_prior_graph_stamp_and_manifest(tmp_path, monkeypatch, force): + """A genuine native syntax failure cannot replace prior accepted policy-two products.""" + monkeypatch.chdir(tmp_path) + paths, _ = prior_epoch(tmp_path, monkeypatch) + out = tmp_path / "graphify-out" + products = [out / "graph.json", out / "manifest.json", out / QT_STATE_FILE, out / ".graphify_root"] + before = {path: path.read_bytes() for path in products} + paths[1].write_text('void Backend::run( { emit changed(); }\n', encoding="utf-8") + with pytest.raises(SystemExit) as failure: + cli(tmp_path, monkeypatch, "update", force=force) + assert failure.value.code not in (0, None) + assert before == {path: path.read_bytes() for path in products} + paths[1].write_text(SOURCES["backend.cpp"], encoding="utf-8") + recovered = cli(tmp_path, monkeypatch, "update") + assert recovered and not inspect_qt_analysis(tmp_path, out, paths).changed diff --git a/tests/test_qt_cpp_syntax.py b/tests/test_qt_cpp_syntax.py new file mode 100644 index 0000000000..3a9dde0fa3 --- /dev/null +++ b/tests/test_qt_cpp_syntax.py @@ -0,0 +1,137 @@ +"""Original-byte Qt recovery, false-positive rejection and bounded failure.""" +from __future__ import annotations + +import pytest + +from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp, is_qt_cpp_source +from graphify.extractors.qt_cpp_mapping import map_cpp +from graphify.extractors.qt_cpp_syntax import QtCppError, lexical_code, read_cpp, source_span +from tests.qt_cpp_test_helpers import collect, facts, write +from graphify.extractors.qt_cpp_facts import qt_metadata + + +@pytest.mark.parametrize("section,access", [("public Q_SLOTS", "public"), ("protected Q_SLOTS", "protected"), + ("private Q_SLOTS", "private"), ("Q_SLOTS", "public"), + ("Q_SIGNALS", "public"), ("signals", "public")]) +def test_access_sections_keep_original_roles_offsets(tmp_path, section, access): + source = f'class B : public QObject {{ Q_OBJECT {section}: void changed(int x); }};' + path = write(tmp_path, "b.hpp", source) + result = collect(tmp_path, {"b.hpp": source}) + member = qt_metadata(facts(result, "member", "changed")[0]) + assert member["access"] == access + assert member["roles"] == (["signal"] if section in {"signals", "Q_SIGNALS"} else ["slot"]) + span = member["span"] + assert path.read_bytes()[span["start_byte"]:span["end_byte"]] == b"void changed(int x);" + unit = read_cpp(path, tmp_path) + assert len(unit.source) == len(unit.parsed_source) == len(unit.code) + + +def test_annotations_legacy_calls_and_ordinary_emit_are_distinct(tmp_path): + source = '''class B : public QObject { Q_OBJECT public: Q_SIGNAL void changed(int); Q_SLOT void accept(int); }; +void emit() {} void install(B *s,B *r) { +QObject::connect(s, SIGNAL(changed(int)), r, SLOT(accept(int))); +emit(); emit s->changed(1); Q_EMIT r->changed(2); +}''' + unit = read_cpp(write(tmp_path, "calls.cpp", source), tmp_path) + assert not unit.tree.has_error + assert b"void emit()" in unit.parsed_source and b"emit();" in unit.parsed_source + assert b"emit s" not in unit.parsed_source + assert unit.source == source.encode() + result = collect(tmp_path, {"calls.cpp": source}) + assert qt_metadata(facts(result, "member", "changed")[0])["roles"] == ["signal"] + assert qt_metadata(facts(result, "member", "accept")[0])["roles"] == ["slot"] + + +def test_comments_strings_raw_literals_and_preprocessor_definitions_are_inert(tmp_path): + source = '''// QML_ELEMENT Q_OBJECT +/* Q_PROPERTY(int bad READ bad) */ +const char *a = "QML_NAMED_ELEMENT(Fake)"; +const char *b = R"tag(QML_ELEMENT // Q_OBJECT)tag"; +#define DECLARE_QML QML_ELEMENT +class Ordinary {}; +''' + path = write(tmp_path, "ordinary.cpp", source) + unit = read_cpp(path, tmp_path) + assert unit.macros == [] + assert lexical_code(source.encode()).count(b"\n") == source.count("\n") + result = collect(tmp_path, {"ordinary.cpp": source}) + assert facts(result, "registration") == [] + assert not is_qt_cpp_source(write(tmp_path, "strings.cpp", 'const char *s = "QObject QML_ELEMENT";')) + + +def test_unicode_crlf_macro_spans_are_original_bytes(tmp_path): + source = '// café ☃\r\nclass B : public QObject {\r\nQ_OBJECT\r\nQML_NAMED_ELEMENT(B)\r\n};\r\n' + unit = read_cpp(write(tmp_path, "B.hpp", source), tmp_path) + macro = next(item for item in unit.macros if item["name"] == "QML_NAMED_ELEMENT") + start = source.encode().index(b"QML_NAMED_ELEMENT") + assert macro["span"] == source_span(source.encode(), start, start + len(b"QML_NAMED_ELEMENT(B)")) + assert macro["span"]["start_row"] == 3 and macro["span"]["start_column"] == 0 + assert unit.parsed_source.count(b"\r\n") == 5 + + +@pytest.mark.parametrize("source", ["class B { Q_OBJECT Q_PROPERTY(int bad", "class B : QObject { Q_OBJECT void broken( };", "\xff"]) +def test_invalid_qt_source_is_explicit_failure_without_authoritative_overlay(tmp_path, source): + path = tmp_path / "bad.hpp" + path.write_bytes(source.encode() if source != "\xff" else b"Q_OBJECT \xff") + result = {"nodes": [], "edges": []} + enrich_qt_cpp([path], [result], root=tmp_path) + assert result["nodes"] == result["edges"] == [] + assert result["qml_failures"][0]["code"] in {"QT_CPP_SYNTAX", "QT_CPP_READ"} + assert str(tmp_path) not in str(result["diagnostics"]) + + +def test_non_qt_malformed_cpp_is_not_new_failure(tmp_path): + path = write(tmp_path, "ordinary.cpp", "void unfinished(") + result = {"nodes": [], "edges": []} + enrich_qt_cpp([path], [result], root=tmp_path) + assert "qml_failures" not in result + + +def test_read_limits_and_root_rejection(tmp_path, monkeypatch): + path = write(tmp_path, "large.hpp", "Q_OBJECT " * 20) + monkeypatch.setattr("graphify.extractors.qt_cpp_syntax.MAX_CPP_BYTES", 10) + with pytest.raises(QtCppError, match="5 MB") as failure: + read_cpp(path, tmp_path) + assert failure.value.code == "QT_CPP_LIMIT" + with pytest.raises(QtCppError) as outside: + read_cpp(path, tmp_path / "other") + assert outside.value.code == "QT_CPP_ROOT" + + +def test_missing_canonical_ids_are_not_reconstructed(tmp_path): + unit = read_cpp(write(tmp_path, "b.hpp", "class B: public QObject { Q_OBJECT public: Q_INVOKABLE void go(void); };"), tmp_path) + mapping = map_cpp(unit, [], [], root=tmp_path) + assert mapping.classes[0]["node_id"] == "" + assert mapping.functions[0]["node_id"] == "" + assert mapping.functions[0]["signature"] == "go()" + + +def test_explicit_member_pointer_cast_recovery_preserves_source_signature(tmp_path): + source = 'void install(){ QObject::connect(s, static_cast(&Sender::changed), r, &Receiver::accept); }' + unit = read_cpp(write(tmp_path, "cast.cpp", source), tmp_path) + assert not unit.tree.has_error and unit.source == source.encode() and unit.code == source.encode() + pointer = b"&Sender::changed" + offset = source.encode().index(pointer) + assert unit.parsed_source[offset:offset + len(pointer)] == pointer + assert len(unit.parsed_source) == len(source.encode()) + assert b"static_cast" not in unit.parsed_source + + +def test_overlay_root_rejection_happens_before_admission_read(tmp_path, monkeypatch): + path = tmp_path / "outside.hpp" + path.write_text("class B { Q_OBJECT };", encoding="utf-8") + def fail_read(_): + raise AssertionError("out-of-root source was read") + monkeypatch.setattr("graphify.extractors.qt_cpp_exposure.is_qt_cpp_source", fail_read) + result = {"nodes": [], "edges": []} + enrich_qt_cpp([path], [result], root=tmp_path / "allowed") + assert result["qml_failures"][0]["code"] == "QT_CPP_ROOT" + assert result["nodes"] == result["edges"] == [] + + +def test_reference_pointer_parameter_names_are_not_part_of_type(tmp_path): + result = collect(tmp_path, {"b.hpp": 'class B: public QObject { Q_OBJECT public: void accept(const int &value, QObject *object, int &&moved); };'}) + member = qt_metadata(facts(result, "member", "accept")[0]) + assert member["parameter_names"] == ["value", "object", "moved"] + assert member["parameter_types"] == ["const int&", "QObject*", "int&&"] + assert member["signature"] == "accept(const int&,QObject*,int&&)" diff --git a/tests/test_qt_cpp_upgrade_invalidation.py b/tests/test_qt_cpp_upgrade_invalidation.py new file mode 100644 index 0000000000..1c46819074 --- /dev/null +++ b/tests/test_qt_cpp_upgrade_invalidation.py @@ -0,0 +1,222 @@ +"""REQ-QML-018-AC02 same-version upgrades retire stale syntax and analysis epochs.""" +from __future__ import annotations + +import pytest + +import graphify.__main__ as entrypoint +import graphify.cache as cache +import graphify.extract as extraction +import graphify.qt_incremental as policy +from graphify.qt_analysis_state import commit_qt_analysis, inspect_qt_analysis +from graphify.build import build_from_json +from graphify.detect import save_manifest +from graphify.export import to_json +from tests.test_qt_final_incremental_parity import clean, normalized, published + + +CPP = { + "unused": "int helper(){return 1;}\nvoid use(){ Q_UNUSED(helper()); }\n", + "default": "struct Value {};\nvoid use(const Value &value = {}) {}\n", +} + + +def source(root, syntax): + path = root / "adapter.cpp" + path.write_text(CPP[syntax], encoding="utf-8") + return path + + +def old_syntax(path): + # This is the real generic parser before the Qt-normalization adapter, + # producing the historical accepted cache shape from the same source bytes. + return extraction._extract_generic(path, extraction._CPP_CONFIG) + + +@pytest.mark.parametrize("syntax", CPP) +def test_req_qml018_ac02_schema_retires_real_same_package_syntax_cache(tmp_path, monkeypatch, syntax): + """The fork upgrade changes extractor code without changing package version.""" + path = source(tmp_path, syntax) + before = path.read_bytes(), cache._EXTRACTOR_VERSION + old = old_syntax(path) + with monkeypatch.context() as historical: + historical.setattr(cache, "_AST_CACHE_SCHEMA", 5) + cache.save_cached(path, old, root=tmp_path) + old_dir = cache.cache_dir(tmp_path) + assert cache.load_cached(path, root=tmp_path) is not None + assert cache.load_cached(path, root=tmp_path) is None + assert not old_dir.exists() + assert before == (path.read_bytes(), cache._EXTRACTOR_VERSION) + assert cache.cache_dir(tmp_path).name.endswith(f"-s{cache._AST_CACHE_SCHEMA}") + + +def test_req_qml018_ac02_policy_epoch_refreshes_same_package_unchanged_cpp(tmp_path, monkeypatch): + """Only the analysis policy changes; source and installed package set do not.""" + path = source(tmp_path, "unused") + out = tmp_path / "graphify-out" + with monkeypatch.context() as historical: + historical.setattr(policy, "QT_POLICY_VERSION", 1) + old = inspect_qt_analysis(tmp_path, out, [path]) + commit_qt_analysis(out, old) + current = inspect_qt_analysis(tmp_path, out, [path]) + assert current.changed and current.fingerprint != old.fingerprint + assert current.has_qt and current.current_has_qt + + +@pytest.mark.parametrize("text", [CPP["unused"], CPP["default"], + "void use(const T &value = { /* empty */ }) {}"]) +def test_req_qml018_ac02_new_syntax_candidates_reparse_only_accepted_cpp(tmp_path, text): + """Discovery remains caller-owned; no adjacent source or root is created.""" + path = tmp_path / "adapter.cpp" + path.write_text(text, encoding="utf-8") + before = set(tmp_path.iterdir()) + assert policy.requires_native_refresh([path]) + assert set(tmp_path.iterdir()) == before + + +def test_req_qml018_ac02_unrelated_plain_cpp_retains_actual_warm_cache(tmp_path, monkeypatch): + """An ordinary call/nonempty initializer keeps the established AST fast path.""" + path = tmp_path / "ordinary.cpp" + path.write_text("void helper(){} void use(){ int values[] = {1,2}; helper(); }", encoding="utf-8") + assert not policy.requires_native_refresh([path]) + first = extraction.extract([path], root=tmp_path, cache_root=tmp_path, parallel=False) + assert cache.load_cached(path, root=tmp_path) + observed, real = [], extraction._safe_extract_with_xaml_root + + def observe(extractor, input_path, root): + observed.append(input_path) + return real(extractor, input_path, root) + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe) + warm = extraction.extract([path], root=tmp_path, cache_root=tmp_path, parallel=False) + assert not observed and warm == first + + +def historical_publication(root, monkeypatch, syntax): + """Publish actual pre-adapter parser output/cache with the former policy epoch. + + Generic builder/export and manifest/stamp APIs produce the historical accepted + state; current update/extract functions are never replaced by fixture fakes. + """ + path = source(root, syntax) + keep = root / "keep.py" + keep.write_text("def helper(): return 7\n\ndef retained(): return helper()\n", encoding="utf-8") + paths, out = [path, keep], root / "graphify-out" + with monkeypatch.context() as historical: + historical.setattr(cache, "_AST_CACHE_SCHEMA", 5) + historical.setattr(policy, "QT_POLICY_VERSION", 1) + old = old_syntax(path) + cache.save_cached(path, old, root=root) + assert cache.load_cached(path, root=root) + unchanged = extraction.extract([keep], root=root, cache_root=root, parallel=False) + graph = build_from_json({"nodes": [*old["nodes"], *unchanged["nodes"]], + "edges": [*old["edges"], *unchanged["edges"]]}, root=root) + assert to_json(graph, {}, str(out / "graph.json"), force=True) + save_manifest({"code": [str(item) for item in paths]}, manifest_path=str(out / "manifest.json"), + kind="ast", root=root, scan_corpus={str(item) for item in paths}) + state = inspect_qt_analysis(root, out, paths) + commit_qt_analysis(out, state) + assert not inspect_qt_analysis(root, out, paths).changed + old_dir = cache.cache_dir(root) + return paths, state.fingerprint, old_dir + + +def cli(root, monkeypatch, operation, *, force=False): + # These documented production options isolate skill maintenance and tips; + # the real console parser, locking, update/extract and persistence all run. + monkeypatch.setenv("GRAPHIFY_NO_AUTO_REFRESH", "1") + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + args = ["graphify", operation, str(root), "--no-cluster"] + if operation == "extract": + args += ["--update", "--code-only"] + if force: + args.append("--force") + monkeypatch.setattr(entrypoint.sys, "argv", args) + try: + entrypoint.main() + except SystemExit as result: + if result.code not in (None, 0): + raise + return published(root) + + +def unchanged_python(graph): + nodes, edges = normalized(graph) + ids = {identity for identity, data in graph.nodes(data=True) if data.get("source_file") == "keep.py"} + return {identity: nodes[identity] for identity in ids}, {edge for edge in edges if edge[0] in ids and edge[1] in ids} + + +@pytest.mark.parametrize("operation", ["update", "extract"]) +@pytest.mark.parametrize("syntax", CPP) +def test_req_qml018_ac02_actual_cli_refreshes_old_epoch_without_source_or_package_change( + tmp_path, monkeypatch, operation, syntax +): + """Upgrade success: stale accepted graph/cache cannot survive unchanged inputs.""" + monkeypatch.chdir(tmp_path) + paths, old_fingerprint, old_dir = historical_publication(tmp_path, monkeypatch, syntax) + before = {item.name: item.read_bytes() for item in paths}, cache._EXTRACTOR_VERSION + initial = published(tmp_path) + untouched = unchanged_python(initial) + observed, real = [], extraction._safe_extract_with_xaml_root + + def observe(extractor, input_path, root): + observed.append(input_path) + return real(extractor, input_path, root) + + # Observe the genuine parser dispatch, rather than infer it from a graph + # that could already retain some nested known calls under the old adapter. + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe) + current = cli(tmp_path, monkeypatch, operation) + assert paths[0] in observed + assert not old_dir.exists() and cache.cache_dir(tmp_path).name.endswith(f"-s{cache._AST_CACHE_SCHEMA}") + assert cache.load_cached(paths[0], root=tmp_path) is None + assert cache.load_cached(paths[1], root=tmp_path) is not None + state = inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths) + assert not state.changed and state.fingerprint != old_fingerprint + assert before == ({item.name: item.read_bytes() for item in paths}, cache._EXTRACTOR_VERSION) + assert unchanged_python(current) == untouched + if syntax == "unused": + nodes = {data.get("label"): identity for identity, data in current.nodes(data=True) + if data.get("source_file") == "adapter.cpp"} + pairs = {nodes["use()"], nodes["helper()"]} + assert any({source, target} == pairs and data.get("relation") == "calls" + for source, target, data in current.edges(data=True)) + assert any(call["callee"] == "Q_UNUSED" for call in old_syntax(paths[0])["raw_calls"]) + direct = extraction.extract_cpp(paths[0]) + assert not direct.get("parse_errors") + assert not any(call["callee"] == "Q_UNUSED" for call in direct.get("raw_calls", [])) + assert normalized(current) == normalized(clean(tmp_path, tmp_path / ".cold-cache")) + assert normalized(current) == normalized(clean(tmp_path, tmp_path / ".cold-cache")) + graph_bytes = (tmp_path / "graphify-out/graph.json").read_bytes() + repeated = cli(tmp_path, monkeypatch, operation) + assert normalized(repeated) == normalized(current) + assert (tmp_path / "graphify-out/graph.json").read_bytes() == graph_bytes + + +def products(root): + out = root / "graphify-out" + return {name: (out / name).read_bytes() for name in ("graph.json", "manifest.json", ".qt_analysis.json")} + + +def ast_entries(root): + base = root / "graphify-out/cache/ast" + return {path.relative_to(base).as_posix(): path.read_bytes() for path in base.rglob("*.json")} + + +@pytest.mark.parametrize("force", [False, True]) +def test_req_qml018_ac02_current_epoch_failure_keeps_real_cache_and_products(tmp_path, monkeypatch, force): + """Malformed new input cannot poison current syntax cache or published state.""" + monkeypatch.chdir(tmp_path) + path = source(tmp_path, "unused") + valid = path.read_bytes() + accepted = cli(tmp_path, monkeypatch, "update") + cache.save_cached(path, extraction.extract_cpp(path), root=tmp_path) + before, cached = products(tmp_path), ast_entries(tmp_path) + assert cached and all(f"-s{cache._AST_CACHE_SCHEMA}/" in name for name in cached) + path.write_text("void use(){ Q_UNUSED(helper()) broken( }", encoding="utf-8") + with pytest.raises(SystemExit) as failure: + cli(tmp_path, monkeypatch, "update", force=force) + assert failure.value.code == 1 + assert products(tmp_path) == before and ast_entries(tmp_path) == cached + path.write_bytes(valid) + assert normalized(cli(tmp_path, monkeypatch, "update")) == normalized(accepted) + assert ast_entries(tmp_path) == cached diff --git a/tests/test_qt_cross_event_occurrences.py b/tests/test_qt_cross_event_occurrences.py new file mode 100644 index 0000000000..5c6625f40e --- /dev/null +++ b/tests/test_qt_cross_event_occurrences.py @@ -0,0 +1,175 @@ +"""INC-QML-37: independent cross-language event occurrences keep bounded metadata.""" +from __future__ import annotations + +import json +import os +import stat + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis +from tests.test_qt_access_providers import BACKEND, QML + + +def sources(root, count, *, mixed=True): + """Each actual source call alternates native/QML direction and operation.""" + statements = [] + for index in range(count): + operation = "disconnect" if mixed and index % 4 >= 2 else "connect" + parameters = ('root, SIGNAL(closed()), backend, SLOT(close())' if index % 2 == 0 + else 'backend, SIGNAL(changed()), root, SLOT(refresh())') + statements.append(f' QObject::{operation}({parameters});\n') + source = BACKEND + f'''void use(Backend *backend) {{ QQuickView view; + view.setSource(QUrl("{(root / 'Main.qml').as_uri()}")); auto root = view.rootObject(); +''' + ''.join(statements) + '}\n' + return {"access.cpp": source, "Main.qml": QML, + "keep.py": "def helper(): return 7\ndef untouched(): return helper()\n"} + + +def assert_events(graph, count): + """Site-owned role nodes and edges retain their own operation/direction/span.""" + events = [(identity, node, qt_metadata(node)) for identity, node in graph.nodes(data=True) + if qt_metadata(node).get("kind") in {"connect", "disconnect"}] + assert len(events) == count + for identity, node, md in events: + assert md["status"] == "resolved", md + endpoints = [(role_id, role, qt_metadata(role)) for role_id, role in graph.nodes(data=True) + if qt_metadata(role).get("kind") == "event_endpoint" + and qt_metadata(role).get("owner_id") == identity] + assert {values["role"] for _, _, values in endpoints} == {"signal", "receiver"} + for role_id, role, values in endpoints: + assert values["span"] == md["span"] and role["source_location"] == node["source_location"] + assert values["bridge_direction"] == md["bridge_direction"] + assert graph.has_edge(identity, role_id) + target = values["target_id"] + assert graph.has_edge(role_id, target) + available = graph.get_edge_data(role_id, target) + available = list(available.values()) if graph.is_multigraph() else [available] + matching = [edge for edge in available + if edge.get("context") == f'qt_qml_{md["kind"]}_{values["role"]}'] + assert len(matching) == 1 + edge = matching[0] + proof = qt_metadata(edge) + assert edge["relation"] == "references" + assert edge["context"] == f'qt_qml_{md["kind"]}_{values["role"]}' + assert proof["bridge_direction"] == md["bridge_direction"] and proof["span"] == md["span"] + assert not any(key.startswith("raw_values/") for key in edge["metadata"]["qt"]["raw_values"]) + assert any(node.get("source_file") == "keep.py" for _, node in graph.nodes(data=True)) + + +@pytest.mark.parametrize("count", [1, 2, 3, 4, 8, 16]) +@pytest.mark.parametrize("mixed", [False, True]) +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml016_ac02_ac04_cross_language_occurrences_keep_own_transport(tmp_path, count, mixed, directed): + """Real facade/build/writer/reload retain every repeated connect/disconnect occurrence.""" + result = analysis(tmp_path, sources(tmp_path, count, mixed=mixed)) + # Decode every producer edge before graph assembly; repeated encoding cannot + # quietly erase an earlier valid endpoint through the projection guard. + for edge in result["edges"]: + qt_metadata(edge) + graph = build_from_json(result, root=tmp_path, directed=directed) + assert_events(graph, count) + output = tmp_path / "accepted.json" + assert to_json(graph, {}, str(output), force=True) + assert_events(load_node_link_graph(json.loads(output.read_bytes())), count) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac04_cross_event_edits_removal_and_repeat_match_clean(tmp_path, monkeypatch, operation): + """Real C++-only and QML-only edits retire stale endpoints and preserve unrelated code.""" + from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated + + analysis(tmp_path, sources(tmp_path, 8)) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + assert_events(initial, 8) + assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold-cache")) + other = unrelated(initial) + cpp = tmp_path / "access.cpp" + original = cpp.read_bytes() + statements = original.splitlines(keepends=True) + cpp.write_bytes(b''.join(line for line in statements if b'QObject::disconnect' not in line)) + edited = run(tmp_path, monkeypatch, operation, [cpp]) + assert_events(edited, 4) + assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited-cache")) + assert unrelated(edited) == other + qml = tmp_path / "Main.qml" + original_qml = qml.read_bytes() + qml.write_bytes(original_qml.replace(b'signal closed()', b'signal stopped()')) + changed = run(tmp_path, monkeypatch, operation, [qml]) + assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".qml-cache")) + assert unrelated(changed) == other + qml.write_bytes(original_qml) + cpp.write_bytes(original) + restored = run(tmp_path, monkeypatch, operation, [cpp, qml]) + assert_events(restored, 8) + assert normalized(restored) == normalized(initial) + output = tmp_path / "graphify-out/graph.json" + before = output.read_bytes(), output.stat().st_mtime_ns + assert normalized(run(tmp_path, monkeypatch, operation)) == normalized(restored) + assert (output.read_bytes(), output.stat().st_mtime_ns) == before + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac03_event_policy_refreshes_unchanged_source(tmp_path, monkeypatch, operation): + """The previous policy checkpoint cannot authorize the new derived event contract.""" + import graphify.qt_incremental as policy + from tests.test_qt_final_incremental_parity import normalized, run + + analysis(tmp_path, sources(tmp_path, 4)) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + with monkeypatch.context() as older: + older.setattr(policy, "QT_POLICY_VERSION", policy.QT_POLICY_VERSION - 1) + prior = run(tmp_path, monkeypatch, operation) + checkpoint = tmp_path / "graphify-out/.qt_analysis.json" + old_bytes = checkpoint.read_bytes() + refreshed = run(tmp_path, monkeypatch, operation, []) + assert checkpoint.read_bytes() != old_bytes + assert normalized(refreshed) == normalized(prior) + assert_events(refreshed, 4) + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary") +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac06_cross_event_publication_failure_retains_prior_and_retries(tmp_path, monkeypatch, operation): + """Actual destination failure preserves accepted events/cache before successful repaired retry.""" + from graphify.cache import cache_dir + from graphify.watch import _rebuild_code + from tests.test_qt_final_incremental_parity import clean, normalized, run + from tests.test_qt_readonly_publication import durable + + analysis(tmp_path, sources(tmp_path, 4)) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + accepted = durable(tmp_path) + cached = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")} + assert cached + analysis(tmp_path, sources(tmp_path, 8)) + cpp = tmp_path / "access.cpp" + target = tmp_path / "graphify-out/graph.json" + target.chmod(stat.S_IREAD) + try: + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, monkeypatch, operation, [cpp]) + assert rejected.value.code == 1 + else: + assert not _rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True) + assert durable(tmp_path) == accepted + assert all(path.read_bytes() == data for path, data in cached.items()) + assert_events(initial, 4) + finally: + target.chmod(stat.S_IWRITE) + repaired = run(tmp_path, monkeypatch, operation, [cpp]) + assert_events(repaired, 8) + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired-cache")) + accepted = durable(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation)) == normalized(repaired) + assert durable(tmp_path) == accepted diff --git a/tests/test_qt_declared_api_shapes.py b/tests/test_qt_declared_api_shapes.py new file mode 100644 index 0000000000..c32ae61e2b --- /dev/null +++ b/tests/test_qt_declared_api_shapes.py @@ -0,0 +1,174 @@ +"""REQ-QML-018-AC03/AC04/AC06: original C++ declarator shape bounds API authority.""" +from __future__ import annotations + +import copy + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt +from graphify.qt_qml_projection import allows_qt_qml_edge +from tests.qt_adoption_fixture import NATIVE, sources +from tests.qt_analysis_helpers import analysis, sites + + +@pytest.mark.parametrize("declaration", [ + "Backend **makeBackend()", "Backend *&makeBackend()", "Backend &&makeBackend()", + "Backend makeBackend()", "Backend (*makeBackend())[2]", "Backend (*makeBackend())()", +]) +def test_req_qml018_ac03_factory_unsupported_shape_cannot_lend_api(tmp_path, declaration): + """Valid syntax with multiple indirections, values or compound returns stays opaque.""" + native = NATIVE.replace("Backend *makeBackend()", declaration) + result = analysis(tmp_path, sources(tmp_path, native=native)) + assert not sites(result, "context_binding") + + +@pytest.mark.parametrize("declaration", [ + "Backend **backend", "Backend *&backend", "Backend backend[2]", + "Backend (*backend)()", "Backend backend", +]) +def test_req_qml018_ac03_field_unsupported_shape_cannot_lend_api(tmp_path, declaration): + """A field's named base class alone does not establish one object endpoint.""" + native = NATIVE.replace("Backend *backend", declaration) + result = analysis(tmp_path, sources(tmp_path, "factory->backend", native=native)) + assert not sites(result, "context_binding") + + +@pytest.mark.parametrize("raw,getter", [ + ("Service**", "Service **"), ("Service*&", "Service *&"), + ("Service&&", "Service &&"), ("Service", "Service "), +]) +def test_req_qml018_ac04_child_unsupported_shape_cannot_lend_signal(tmp_path, raw, getter): + """Matching property/getter base classes cannot erase unsupported pointer depth.""" + native = NATIVE.replace("Q_PROPERTY(Service* service", "Q_PROPERTY(" + raw + " service") + native = native.replace("Service *service()", getter + "service()") + result = analysis(tmp_path, sources(tmp_path, native=native)) + assert len(sites(result, "context_binding")) == 1 + by_id = {node["id"]: node for node in result["nodes"]} + assert not any(qt_metadata(by_id[qt_metadata(node)["endpoint_proof"]["class_id"]]).get("class_name") == "Service" + for node in sites(result, "context_access")) + assert not sites(result, "context_subscription") + + +@pytest.mark.parametrize("declaration,expression", [ + ("Factory **factory", "factory->makeBackend()"), + ("Factory *&factory", "factory->makeBackend()"), + ("Factory *factory[2]", "factory->makeBackend()"), + ("Factory (*factory)()", "factory->makeBackend()"), + ("Factory factory", "factory->makeBackend()"), + ("Factory *factory", "factory.makeBackend()"), +]) +def test_req_qml018_ac03_root_shape_must_match_literal_member_operator(tmp_path, declaration, expression): + """The selected lexical declaration retains its shape before member traversal.""" + inputs = sources(tmp_path, expression) + inputs["access.cpp"] = inputs["access.cpp"].replace("Factory *factory", declaration) + assert not sites(analysis(tmp_path, inputs), "context_binding") + + +@pytest.mark.parametrize("root,expression,returned", [ + ("Factory *const factory", "factory->makeBackend()", "Backend * const"), + ("const Factory *factory", "factory->makeBackend()", "const Backend *"), + ("Factory &factory", "factory.makeBackend()", "Backend *"), + ("Factory factory", "factory.makeBackend()", "Backend *"), +]) +def test_req_qml018_ac03_single_pointer_reference_and_cv_shapes_remain_inspectable(tmp_path, root, expression, returned): + """Supported root/value and API forms retain lexical CV rather than stripping it.""" + native = NATIVE.replace("Backend *makeBackend()", returned + " makeBackend()") + inputs = sources(tmp_path, expression, native=native) + inputs["access.cpp"] = inputs["access.cpp"].replace("Factory *factory", root) + result = analysis(tmp_path, inputs) + assert len(sites(result, "context_binding")) == 1 + member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend") + assert qt_metadata(member)["api_type_spelling"].strip() == returned.strip() + exposure = sites(result, "context_exposure")[0] + assert qt_metadata(exposure)["provider_root_type_spelling"].strip() == root.replace("factory", "").strip() + + +@pytest.mark.parametrize("corruption", ["factory", "property", "getter", "root", "operator"]) +def test_req_qml018_ac06_consumer_rejects_corrupt_declarator_shape(tmp_path, corruption): + """Persisted canonical targets cannot lend API authority after shape corruption.""" + result = analysis(tmp_path, sources(tmp_path)) + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + by_id = {node["id"]: node for node in nodes} + edge = next(edge for edge in edges if edge.get("context") == "qt_context_subscription") + source, target = by_id[edge["source"]], by_id[edge["target"]] + if corruption in {"root", "operator"}: + chosen = next(node for node in nodes if qt_metadata(node).get("kind") == "context_exposure") + if corruption == "root": + update_qt(chosen, provider_root_type_spelling="Factory **") + else: + steps = qt_metadata(chosen)["provider_expression_steps"] + steps[0]["operator"] = "." + update_qt(chosen, provider_expression_steps=steps) + else: + kind, name = {"factory": ("member", "makeBackend"), "property": ("property", "service"), + "getter": ("member", "service")}[corruption] + chosen = next(node for node in nodes if qt_metadata(node).get("kind") == kind + and qt_metadata(node).get("raw_name") == name) + update_qt(chosen, api_type_spelling="Backend **" if corruption == "factory" else "Service *&") + assert not allows_qt_qml_edge(source, target, edge, source_id=source["id"], target_id=target["id"], nodes=by_id) + + +@pytest.mark.parametrize("mode", ["address", "local", "this", "reference_field"]) +def test_req_qml018_ac03_source_owned_object_root_forms_retain_api(tmp_path, mode): + """Address-of, initialized locals, this and reference fields retain bounded lexical authority.""" + inputs = sources(tmp_path, "&backend" if mode == "address" else "local->makeBackend()" if mode == "local" + else "factory->backend" if mode == "reference_field" else "factory->makeBackend()") + if mode == "address": + inputs["access.cpp"] = inputs["access.cpp"].replace("Backend *backend", "Backend backend") + elif mode == "local": + inputs["access.cpp"] = inputs["access.cpp"].replace(" QQmlApplicationEngine", " Factory *local = factory; QQmlApplicationEngine") + elif mode == "reference_field": + inputs["native.h"] = inputs["native.h"].replace("Backend *backend", "Backend &backend") + else: + body = 'void expose() { QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("backend", this);' + body += 'engine.load(QUrl("' + (tmp_path / "Main.qml").as_uri() + '")); }' + inputs["native.h"] = inputs["native.h"].replace("public: Service *service()", "public: " + body + " Service *service()") + inputs["access.cpp"] = '#include "native.h"\n' + result = analysis(tmp_path, inputs) + assert len(sites(result, "context_binding")) == 1 + assert len(sites(result, "context_subscription")) == 2 + + +def test_req_qml018_ac03_opaque_root_alias_retains_shape_uncertainty(tmp_path): + """A pointer typedef is not normalized into a convenient one-object root.""" + inputs = sources(tmp_path) + inputs["access.cpp"] = inputs["access.cpp"].replace("void use(Factory *factory", "using Handle = Factory**;\nvoid use(Handle factory") + result = analysis(tmp_path, inputs) + assert not sites(result, "context_binding") + md = qt_metadata(sites(result, "context_exposure")[0]) + assert md["provider_root_type_spelling"] == "Handle" + assert md["provider_type_reason"] == "native_type_alias_unsupported" + + +def test_req_qml018_ac03_reference_return_uses_canonical_generic_admission(tmp_path): + """An admitted reference factory supplies its exact generic callable and declared API.""" + inputs = sources(tmp_path, native=NATIVE.replace("Backend *makeBackend()", "Backend &makeBackend()")) + result = analysis(tmp_path, inputs) + member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend") + md = qt_metadata(member) + assert md["api_type_spelling"] == "Backend &" and md["api_type_status"] == "resolved" + assert md["generic_target_id"] in {node["id"] for node in result["nodes"]} + assert len(sites(result, "context_binding")) == 1 + + +@pytest.mark.parametrize("returned,status", [("const Backend *", "resolved"), ("Backend **", "unsupported")]) +def test_req_qml018_ac03_direct_producer_keeps_original_cv_shape_and_byte_position(tmp_path, returned, status): + """Direct declaration facts match the facade over BOM, CRLF and a Unicode prefix.""" + from graphify.extractors.qt_cpp_exposure import _class_facts + from graphify.extractors.qt_cpp_facts import QtFacts + from graphify.extractors.qt_cpp_mapping import map_cpp + from graphify.extractors.qt_cpp_syntax import read_cpp + native = "\ufeff// café\r\n" + NATIVE.replace("Backend *makeBackend()", returned + "makeBackend()").replace("\n", "\r\n") + result = analysis(tmp_path, sources(tmp_path, native=native)) + unit = read_cpp(tmp_path / "native.h", tmp_path) + mapping = map_cpp(unit, result["nodes"], result["edges"], root=tmp_path) + facts = QtFacts(unit) + _class_facts(mapping, facts, classes=mapping.classes) + direct = next(node for node in facts.nodes if qt_metadata(node).get("kind") == "member" + and qt_metadata(node).get("raw_name") == "makeBackend") + facade = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend") + fields = ("api_type_spelling", "api_type_target_id", "api_type_status", "api_type_reason", "api_type_position") + direct_md = qt_metadata(direct) + assert {key: direct_md[key] for key in fields} == {key: qt_metadata(facade)[key] for key in fields} + assert direct_md["api_type_spelling"] == returned and direct_md["api_type_status"] == status + assert unit.source[direct_md["api_type_position"]:].startswith(returned.encode()) diff --git a/tests/test_qt_declared_proof_integrity.py b/tests/test_qt_declared_proof_integrity.py new file mode 100644 index 0000000000..4d9ffc627f --- /dev/null +++ b/tests/test_qt_declared_proof_integrity.py @@ -0,0 +1,100 @@ +"""REQ-QML-018-AC06: borrowed typed declarations retain their source authority.""" +from __future__ import annotations + +import copy + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt +from graphify.qt_qml_projection import allows_qt_qml_edge +from tests.qt_adoption_fixture import NATIVE, QML, sources +from tests.qt_analysis_helpers import analysis + + +def serialized_subscription(result, *, notify=False): + """Copy accepted producer output before altering one consumer-owned proof input.""" + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + by_id = {node["id"]: node for node in nodes} + edge = next(item for item in edges if item.get("context") == "qt_context_subscription" + and bool(qt_metadata(item)["endpoint_proof"].get("notify_property_id")) is notify) + source, target = by_id[edge["source"]], by_id[edge["target"]] + def accepted(): + return allows_qt_qml_edge(source, target, edge, source_id=source["id"], target_id=target["id"], nodes=by_id) + assert accepted() + return nodes, by_id, edge, accepted + + +@pytest.mark.parametrize("corruption", ["getter_arity", "accessor_owner", "accessor_file", "accessor_span", + "accessor_name", "factory_callable", "factory_class", "factory_owner", "property_owner", + "factory_file", "factory_span", "property_file", "property_span", "getter_callable"]) +def test_req_qml018_ac06_typed_hop_rejects_altered_authoritative_declaration(tmp_path, corruption): + """Canonical type IDs do not repair changed source ownership, callable roles or READ arity.""" + nodes, by_id, _, accepted = serialized_subscription(analysis(tmp_path, sources(tmp_path))) + def chosen(kind, name): + return next(node for node in nodes if qt_metadata(node).get("kind") == kind + and qt_metadata(node).get("raw_name") == name) + prop, getter, factory = chosen("property", "service"), chosen("member", "service"), chosen("member", "makeBackend") + accessor = next(node for node in nodes if qt_metadata(node).get("kind") == "property_accessor" + and qt_metadata(node).get("property_id") == prop["id"] and qt_metadata(node).get("accessor_role") == "read") + target = accessor if corruption.startswith("accessor") else prop if corruption.startswith("property") else getter if corruption.startswith("getter") else factory + if corruption == "getter_arity": + update_qt(target, parameter_types=["int"]) + elif corruption.endswith("callable") or corruption == "factory_class": + by_id[qt_metadata(target)["generic_target_id"]]["_callable_class" if corruption == "factory_class" else "_callable"] = corruption == "factory_class" + elif corruption.endswith("owner"): + update_qt(target, owner_id="missing") + elif corruption.endswith("file"): + target["source_file"] = "Main.qml" + elif corruption.endswith("name"): + update_qt(target, raw_name="unrelated") + else: + span = qt_metadata(target)["span"] + span["start_byte"] += 1_000_000 + span["end_byte"] += 1_000_000 + update_qt(target, span=span) + assert not accepted() + + +@pytest.mark.parametrize("corruption", ["owner", "file", "span", "name", "property_owner"]) +def test_req_qml018_ac06_typed_notify_revalidates_property_accessor_provenance(tmp_path, corruption): + """A property-change subscription retains the same owner/name/file/span guard as lookup.""" + native = NATIVE.replace("Q_PROPERTY(int count READ count)", "Q_PROPERTY(int count READ count NOTIFY actualCount)") + native = native.replace("signals: void ready();", "signals: void ready(); void actualCount();") + result = analysis(tmp_path, sources(tmp_path, native=native, qml=QML.replace("function onReady()", "function onCountChanged()"))) + nodes, by_id, edge, accepted = serialized_subscription(result, notify=True) + prop = by_id[qt_metadata(edge)["endpoint_proof"]["notify_property_id"]] + accessor = next(node for node in nodes if qt_metadata(node).get("kind") == "property_accessor" + and qt_metadata(node).get("property_id") == prop["id"] and qt_metadata(node).get("accessor_role") == "notify") + if corruption in {"owner", "property_owner"}: + update_qt(prop if corruption == "property_owner" else accessor, owner_id="missing") + elif corruption == "file": + accessor["source_file"] = "Main.qml" + elif corruption == "name": + update_qt(accessor, raw_name="unrelated") + else: + span = qt_metadata(accessor)["span"] + span["start_byte"] += 1_000_000 + span["end_byte"] += 1_000_000 + update_qt(accessor, span=span) + assert not accepted() + + +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml018_ac04_ac06_reloaded_notify_proof_retains_mapping_identity(tmp_path, directed): + """Persisted NetworkX mappings lend the same accepted NOTIFY authority as raw producer nodes.""" + import json + from graphify.build import build_from_json + from graphify.export import to_json + from graphify.paths import load_node_link_graph + native = NATIVE.replace("Q_PROPERTY(int count READ count)", "Q_PROPERTY(int count READ count NOTIFY actualCount)") + native = native.replace("signals: void ready();", "signals: void ready(); void actualCount();") + result = analysis(tmp_path, sources(tmp_path, native=native, qml=QML.replace("function onReady()", "function onCountChanged()"))) + graph = build_from_json(result, root=tmp_path, directed=directed) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path), force=True) + restored = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + for edge in result["edges"]: + if edge.get("context") == "qt_context_subscription" and qt_metadata(edge)["endpoint_proof"].get("notify_property_id"): + source, target = edge["source"], edge["target"] + assert allows_qt_qml_edge(restored.nodes[source], restored.nodes[target], restored.edges[source, target], + source_id=source, target_id=target, nodes=restored.nodes) diff --git a/tests/test_qt_engine_identity.py b/tests/test_qt_engine_identity.py new file mode 100644 index 0000000000..bca07c94ad --- /dev/null +++ b/tests/test_qt_engine_identity.py @@ -0,0 +1,229 @@ +"""REQ-QML-017-AC03/AC04: declaration identity prevents cross-engine injection.""" +from __future__ import annotations + +import json +import copy + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_identity import CppDeclarationIdentity, source_reference_key +from graphify.extractors.qt_cpp_mapping import map_cpp +from graphify.extractors.qt_cpp_syntax import read_cpp +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites + +BACKEND = '''class Backend : public QObject { + Q_OBJECT Q_PROPERTY(int count READ count) +public: int count() { return 1; } +}; +''' + + +def persisted(root, source, qml=None): + """Analyze synthetic sources and inspect completed production JSON output.""" + result = analysis(root, {"access.cpp": BACKEND + source, + "Main.qml": qml or 'import QtQml\nQtObject { property int value: backend.count }'}) + graph = build_from_json(result, root=root) + output = root / "proof.json" + assert to_json(graph, {}, str(output), force=True) + return result, load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + + +@pytest.mark.parametrize("exposure,qml", [ + ('engine.rootContext()->setContextProperty("backend", backend);', 'property int value: backend.count'), + ('engine.rootContext()->setContextObject(backend);', 'property int value: count'), + ('engine.setInitialProperties({{"backend", backend}});', 'property var backend; property int value: backend.count'), +]) +def test_req_qml017_ac03_disjoint_same_named_engines_cannot_share_provider(tmp_path, exposure, qml): + """An engine destroyed in one block cannot supply a later distinct engine.""" + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use(Backend *backend) {{ + {{ QQmlApplicationEngine engine; {exposure} }} + {{ QQmlApplicationEngine engine; engine.load(QUrl("{url}")); }} +}}''' + result, graph = persisted(tmp_path, source, 'import QtQml\nQtObject { ' + qml + ' }') + assert not sites(result, "context_binding") + assert not sites(result, "context_access") + assert not [edge for _, _, edge in graph.edges(data=True) + if edge.get("context") in {"qt_context_member", "qt_context_exposure", "qt_initial_property"}] + + +def test_req_qml017_ac03_same_engine_positive_retains_persisted_provider(tmp_path): + """One declaration may expose a provider and later load its own component.""" + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use(Backend *backend) {{ QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("backend", backend); + engine.load(QUrl("{url}")); }}''' + result, graph = persisted(tmp_path, source) + binding = sites(result, "context_binding")[0] + access = sites(result, "context_access")[0] + assert qt_metadata(binding)["status"] == "resolved" + assert graph.has_edge(binding["id"], qt_metadata(binding)["component_target_id"]) + assert graph.has_edge(access["id"], qt_metadata(access)["target_id"]) + + +def test_req_qml017_ac03_nested_engine_shadow_does_not_receive_outer_provider(tmp_path): + """A shadow engine loads its document without inheriting an outer exposure.""" + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use(Backend *backend) {{ QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("backend", backend); + {{ QQmlApplicationEngine engine; engine.load(QUrl("{url}")); }} }}''' + result, graph = persisted(tmp_path, source) + assert not sites(result, "context_binding") and not sites(result, "context_access") + assert not [edge for _, _, edge in graph.edges(data=True) if edge.get("context") == "qt_context_member"] + + +def test_req_qml017_ac03_local_provider_lifetime_cannot_supply_later_load(tmp_path): + """Even one engine cannot extend a source-local provider beyond its block.""" + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use() {{ QQmlApplicationEngine engine; + {{ Backend backend; engine.rootContext()->setContextProperty("backend", &backend); }} + engine.load(QUrl("{url}")); }}''' + result, graph = persisted(tmp_path, source) + assert not sites(result, "context_binding") and not sites(result, "context_access") + assert not [edge for _, _, edge in graph.edges(data=True) if edge.get("context") == "qt_context_member"] + + +def test_req_qml017_ac03_engine_and_provider_parameters_have_exact_identity(tmp_path): + """Direct parameters establish one source relationship without caller guesses.""" + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use(QQmlApplicationEngine &engine, Backend *backend) {{ + engine.rootContext()->setContextProperty("backend", backend); engine.load(QUrl("{url}")); }}''' + result, graph = persisted(tmp_path, source) + binding = sites(result, "context_binding")[0] + metadata = qt_metadata(binding) + assert metadata["status"] == "resolved" + assert len(metadata["engine_declaration_id"]) == len(metadata["provider_declaration_id"]) == 64 + assert graph.has_edge(binding["id"], metadata["component_target_id"]) + + +@pytest.mark.parametrize("prefix,engine,role,status,reason", [ + ('QQmlApplicationEngine *engine = new QQmlApplicationEngine; engine = replacement;', 'engine->', "engine", "dynamic", "reassigned_declaration"), + ('QQmlApplicationEngine engine; QQmlApplicationEngine engine;', 'engine.', "engine", "ambiguous", "declaration_ambiguous"), + ('QQmlApplicationEngine engine; backend = other;', 'engine.', "provider", "dynamic", "reassigned_declaration"), +]) +def test_req_qml017_ac03_rejected_identity_preserves_precise_source_diagnostic(tmp_path, prefix, engine, role, status, reason): + """Production rejections keep lexical reasons and publish no provider edges.""" + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use(Backend *backend, Backend *other, QQmlApplicationEngine *replacement) {{ + {prefix} {engine}rootContext()->setContextProperty("backend", backend); + {engine}load(QUrl("{url}")); }}''' + result, graph = persisted(tmp_path, source) + exposure = qt_metadata(sites(result, "context_exposure")[0]) + assert exposure[role + "_identity_status"] == status + assert exposure[role + "_identity_reason"] == reason + assert (exposure["status"], exposure["reason"]) == (status, reason) + assert not sites(result, "context_binding") and not sites(result, "context_access") + assert not [edge for _, _, edge in graph.edges(data=True) if edge.get("context") == "qt_context_member"] + + +@pytest.mark.parametrize("second,expected", [ + ("backend", ("dynamic", "reassigned_declaration")), + ("missing", ("unavailable", "provider_component_scope_unestablished")), +]) +def test_req_qml017_ac03_initial_properties_report_only_shared_identity_failure(tmp_path, second, expected): + """A mixed provider list cannot misreport one item's reason as the whole list.""" + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use(Backend *backend, Backend *other) {{ QQmlApplicationEngine engine; + backend = other; engine.setInitialProperties({{{{"backend", backend}}, {{"extra", {second}}}}}); + engine.load(QUrl("{url}")); }}''' + result, graph = persisted(tmp_path, source, 'import QtQml\nQtObject { property var backend; property var extra; property int value: backend.count }') + metadata = qt_metadata(sites(result, "initial_properties")[0]) + assert (metadata["status"], metadata["reason"]) == expected + assert not sites(result, "context_binding") and not sites(result, "context_access") + assert not [edge for _, _, edge in graph.edges(data=True) if edge.get("context") == "qt_initial_property"] + + +def identity_index(root, source): + """The shared helper consumes real accepted syntax and canonical C++ mapping.""" + result = analysis(root, {"identity.cpp": source}) + unit = read_cpp(root / "identity.cpp", root) + snapshot = copy.deepcopy((result["nodes"], result["edges"])) + mapping = map_cpp(unit, result["nodes"], result["edges"], root=root) + return CppDeclarationIdentity(unit, mapping), unit, result, snapshot + + +def test_req_qml017_ac03_nested_shadow_and_disjoint_declarations_are_distinct(tmp_path): + """Nearest declaration wins only within its own source lexical lifetime.""" + source = '''void use(QQmlApplicationEngine &engine) { + engine.load("outer"); + { QQmlApplicationEngine engine; engine.load("inner"); } + { QQmlApplicationEngine engine; engine.load("other"); } + engine.load("again"); +}''' + index, unit, result, before = identity_index(tmp_path, source) + values = [index.resolve("engine", unit.source.index(('engine.load("' + name).encode())) + for name in ("outer", "inner", "other", "again")] + assert all(value["status"] == "resolved" for value in values) + assert values[0]["declaration_id"] == values[3]["declaration_id"] + assert len({value["declaration_id"] for value in values}) == 3 + assert (result["nodes"], result["edges"]) == before + + +def test_req_qml017_ac04_auto_and_explicit_handles_keep_declaration_identity(tmp_path): + """An initializer call and subsequent handle use share the actual declaration.""" + source = '''void use() { QQmlApplicationEngine engine; + auto root = engine.rootObjects().first(); root->property("value"); + QObject *other = engine.rootObjects().first(); other->property("value"); }''' + index, unit, _, _ = identity_index(tmp_path, source) + for name in ("root", "other"): + assigned = unit.source.index(b"engine.rootObjects", unit.source.index((name + " =").encode())) + used = unit.source.index((name + "->property").encode()) + left, right = index.resolve(name, assigned), index.resolve(name, used) + assert left["status"] == right["status"] == "resolved" + assert left["declaration_id"] == right["declaration_id"] + + +@pytest.mark.parametrize("prefix,expected", [ + ('engine = replacement;', "dynamic"), + ('{ QQmlApplicationEngine *engine = replacement; engine = replacement; }', "resolved"), + ('QQmlApplicationEngine *engine;', "ambiguous"), +]) +def test_req_qml017_ac04_writes_and_duplicate_declarations_fail_closed(tmp_path, prefix, expected): + """An inner write cannot poison the outer declaration; actual writes reject.""" + source = f'void use(QQmlApplicationEngine *replacement) {{ QQmlApplicationEngine *engine = new QQmlApplicationEngine; {prefix} engine->load("main"); }}' + index, unit, _, _ = identity_index(tmp_path, source) + value = index.resolve("engine", unit.source.index(b'engine->load("main")')) + assert value["status"] == expected + assert bool(value["declaration_id"]) is (expected == "resolved") + + +@pytest.mark.parametrize("source", [ + 'void use() { QQmlApplicationEngine engine; if (enabled) { engine.load("main"); } }', + 'void use() { for (QQmlApplicationEngine engine; enabled;) { engine.load("main"); } }', + 'void use() { QQmlApplicationEngine engine; auto f = [&engine] { engine.load("main"); }; }', + 'void use() {\n#if ENABLED\n QQmlApplicationEngine engine;\n#endif\n engine.load("main"); }', +]) +def test_req_qml017_ac04_conditional_and_deferred_lifetimes_have_no_identity(tmp_path, source): + """Conditions and deferred captures cannot authorize a definite engine join.""" + index, unit, _, _ = identity_index(tmp_path, source) + value = index.resolve("engine", unit.source.index(b'engine.load("main")')) + assert value["status"] == "dynamic" and value["declaration_id"] == "" + + +def test_req_qml017_ac04_ids_are_portable_with_original_unicode_bom_crlf_spans(tmp_path): + """IDs contain no checkout root, and span slices retain original byte offsets.""" + source = '\ufeff// café ✓\r\nvoid use(QQmlApplicationEngine &engine) {\r\n engine.load("main"); }' + ids = [] + for folder in (tmp_path / "one", tmp_path / "two"): + folder.mkdir() + index, unit, _, _ = identity_index(folder, source) + value = index.resolve("engine", unit.source.index(b'engine.load("main")')) + assert value["status"] == "resolved" and len(value["declaration_id"]) == 64 + span = value["declaration_span"] + assert unit.source[span["start_byte"]:span["end_byte"]] == b"&engine" + assert span["start_row"] == 1 + ids.append(value["declaration_id"]) + assert ids[0] == ids[1] + + +@pytest.mark.parametrize("identity", [None, "", "a" * 63, "A" * 64, ["a" * 64]]) +def test_req_qml017_ac04_missing_or_corrupted_transport_cannot_fall_back_to_name(identity): + """Legacy names and malformed IDs remain unusable even with an owner key.""" + metadata = {"owner_scope_key": "source_owner", "receiver_reference": "engine", "receiver_declaration_id": identity} + before = copy.deepcopy(metadata) + assert source_reference_key(metadata) is None + assert metadata == before diff --git a/tests/test_qt_engine_identity_updates.py b/tests/test_qt_engine_identity_updates.py new file mode 100644 index 0000000000..ae3a5fd1fe --- /dev/null +++ b/tests/test_qt_engine_identity_updates.py @@ -0,0 +1,189 @@ +"""REQ-QML-017-AC04: engine identity through real refresh, failure and consumers.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.affected import affected_nodes, load_graph +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_consumer_helpers import cli as consumer_cli +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated + +HEADER = '''class Backend : public QObject { Q_OBJECT Q_PROPERTY(int count READ count) +public: int count() { return 1; } }; +class OtherBackend : public QObject { Q_OBJECT Q_PROPERTY(int count READ count) +public: int count() { return 2; } }; +''' + + +def loader(state="same", target="Main.qml"): + """Only lexical source placement changes; URLs refer to accepted resources.""" + expose = 'engine.rootContext()->setContextProperty("backend", backend);' + load = f'engine.load(QUrl("qrc:/ui/{target}"));' + if state == "disjoint": + body = f'{{ QQmlApplicationEngine engine; {expose} }} {{ QQmlApplicationEngine engine; {load} }}' + elif state == "nested": + body = ('QQmlApplicationEngine engine; ' + expose + + ' { QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("backend", other); ' + 'engine.load(QUrl("qrc:/ui/Main.qml")); } engine.load(QUrl("qrc:/ui/Alt.qml"));') + else: + body = 'QQmlApplicationEngine engine; ' + (expose if state != "removed" else "") + load + return '#include "backend.h"\nvoid use(Backend *backend, OtherBackend *other) { ' + body + ' }\n' + + +def fixture(root, state="same"): + sources = { + "backend.h": HEADER, + "loader.cpp": loader(state), + "Main.qml": 'import QtQml\nQtObject { property int observed: backend.count }\n', + "Alt.qml": 'import QtQml\nQtObject { property int alternative: backend.count }\n', + "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml Alt.qml SOURCES backend.h loader.cpp)\n', + "ui.qrc": 'Main.qmlAlt.qml', + "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n', + } + for name, source in sources.items(): + (root / name).write_bytes(source.encode()) + return root / "loader.cpp" + + +def facts(graph, kind): + return {identity: qt_metadata(node) for identity, node in graph.nodes(data=True) + if qt_metadata(node).get("kind") == kind} + + +def assert_binding(graph, filename, provider="Backend"): + """A resolved provider must retain its exact engine and class evidence.""" + bindings = [md for md in facts(graph, "context_binding").values() + if graph.nodes[md["component_target_id"]]["source_file"] == filename] + assert len(bindings) == 1 and bindings[0]["status"] == "resolved" + assert bindings[0]["provider_class_name"] == provider + assert len(bindings[0]["engine_declaration_id"]) == len(bindings[0]["provider_declaration_id"]) == 64 + accesses = [(identity, md) for identity, md in facts(graph, "context_access").items() + if graph.nodes[identity]["source_file"] == filename] + assert len(accesses) == 1 + assert graph.has_edge(accesses[0][0], accesses[0][1]["target_id"]) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml017_ac04_engine_scope_changes_remove_stale_provider_links(tmp_path, monkeypatch, operation): + """Cold/warm and real manual/watch agree across scope, provider and load edits.""" + source = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + cold = clean(tmp_path, tmp_path / ".cold-cache") + assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold) + initial = run(tmp_path, monkeypatch, operation) + assert normalized(initial) == normalized(cold) + assert_binding(initial, "Main.qml") + retired = set(facts(initial, "context_access")) | set(facts(initial, "context_binding")) + kept = unrelated(initial) + + source.write_bytes(loader("disjoint").encode()) + rejected = run(tmp_path, monkeypatch, operation, [source]) + assert not facts(rejected, "context_binding") and not facts(rejected, "context_access") + assert not retired.intersection(rejected) + assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".disjoint-cache")) + + # Nested shadow engines retain their own exact provider and document; + # neither the spelling nor source ordering may select the other provider. + source.write_bytes(loader("nested").encode()) + nested = run(tmp_path, monkeypatch, operation, [source]) + assert_binding(nested, "Main.qml", "OtherBackend") + assert_binding(nested, "Alt.qml", "Backend") + assert normalized(nested) == normalized(clean(tmp_path, tmp_path / ".nested-cache")) + + source.write_bytes(loader("removed").encode()) + removed = run(tmp_path, monkeypatch, operation, [source]) + assert not facts(removed, "context_binding") and not facts(removed, "context_access") + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache")) + source.write_bytes(loader("same", "Alt.qml").encode()) + moved = run(tmp_path, monkeypatch, operation, [source]) + assert_binding(moved, "Alt.qml") + assert not [identity for identity in facts(moved, "context_access") + if moved.nodes[identity]["source_file"] == "Main.qml"] + assert normalized(moved) == normalized(clean(tmp_path, tmp_path / ".moved-cache")) + + # Resource-only changes must invalidate the unchanged C++ loader/provider, + # without preserving the previous component binding from graph context. + resource = tmp_path / "ui.qrc" + valid_resource = resource.read_bytes() + resource.write_bytes(valid_resource.replace(b'alias="Alt.qml"', b'alias="changed.qml"')) + missing = run(tmp_path, monkeypatch, operation, [resource]) + assert not facts(missing, "context_binding") and not facts(missing, "context_access") + assert normalized(missing) == normalized(clean(tmp_path, tmp_path / ".metadata-cache")) + resource.write_bytes(valid_resource) + assert normalized(run(tmp_path, monkeypatch, operation, [resource])) == normalized(moved) + + # An unchanged C++ exposure cannot override a newly declared QML property. + qml = tmp_path / "Alt.qml" + qml.write_bytes(qml.read_bytes().replace(b"property int alternative", b"property var backend; property int alternative")) + shadowed = run(tmp_path, monkeypatch, operation, [qml]) + assert len(facts(shadowed, "context_binding")) == 1 and not facts(shadowed, "context_access") + assert normalized(shadowed) == normalized(clean(tmp_path, tmp_path / ".qml-shadow-cache")) + assert unrelated(shadowed) == kept + before = (tmp_path / "graphify-out/graph.json").read_bytes() + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(shadowed) + assert (tmp_path / "graphify-out/graph.json").read_bytes() == before + + +@pytest.mark.parametrize("operation", ["manual", "force", "watch"]) +def test_req_qml017_ac04_malformed_identity_source_retains_products_and_retries(tmp_path, monkeypatch, operation): + """Actual parser rejection preserves all durable products, including forced CLI.""" + source = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + mode = "manual" if operation == "force" else operation + initial = run(tmp_path, monkeypatch, mode) + assert_binding(initial, "Main.qml") + output = tmp_path / "graphify-out" + products = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root") + before = {name: (output / name).read_bytes() for name in products} + valid = source.read_bytes() + source.write_bytes(valid + b"\nvoid broken({") + if operation == "watch": + from graphify.watch import _rebuild_code + assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + else: + with pytest.raises(SystemExit) as rejected: + if operation == "force": + from tests.test_qt_cpp_upgrade_invalidation import cli + cli(tmp_path, monkeypatch, "update", force=True) + else: + run(tmp_path, monkeypatch, mode, [source]) + assert rejected.value.code == 1 + assert before == {name: (output / name).read_bytes() for name in products} + source.write_bytes(valid) + repaired = run(tmp_path, monkeypatch, mode, [source]) + assert normalized(repaired) == normalized(initial) + assert_binding(repaired, "Main.qml") + assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(initial) + + +def test_req_qml017_ac04_directed_query_affected_and_reload_keep_scoped_provider(tmp_path, monkeypatch, capsys): + """The real directed consumer graph distinguishes the two nested providers.""" + fixture(tmp_path, "nested") + paths = [tmp_path / name for name in ("backend.h", "loader.cpp", "Main.qml", "Alt.qml", "CMakeLists.txt", "ui.qrc", "keep.py")] + from graphify.extract import extract + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not result["failed_sources"] and not result["qml_failures"] + graph = build_from_json(result, root=tmp_path, directed=True) + path = tmp_path / "directed.json" + assert to_json(graph, {}, str(path), force=True) + loaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + assert_binding(loaded, "Main.qml", "OtherBackend") + assert_binding(loaded, "Alt.qml", "Backend") + access, metadata = next((identity, md) for identity, md in facts(loaded, "context_access").items() + if loaded.nodes[identity]["source_file"] == "Main.qml") + assert loaded.has_edge(access, metadata["target_id"]) + assert not loaded.has_edge(metadata["target_id"], access) + text = consumer_cli(monkeypatch, capsys, path, "query", access) + assert "Qt context access: backend.count" in text and "Main.qml" in text + assert "Main.qml" in consumer_cli(monkeypatch, capsys, path, "explain", access) + hits = {hit.node_id for hit in affected_nodes(load_graph(path), metadata["target_id"], relations=["uses"], depth=1)} + assert access in hits + assert not {identity for identity in facts(loaded, "context_access") + if loaded.nodes[identity]["source_file"] == "Alt.qml"}.intersection(hits) diff --git a/tests/test_qt_event_incremental_consumers.py b/tests/test_qt_event_incremental_consumers.py new file mode 100644 index 0000000000..77993f8ba7 --- /dev/null +++ b/tests/test_qt_event_incremental_consumers.py @@ -0,0 +1,143 @@ +"""QML-016-AC04 event edit/removal parity through real update/watch persistence.""" +from __future__ import annotations + +from collections import Counter + +import pytest + +import graphify.__main__ as entrypoint +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.watch import _rebuild_code +from tests.test_qt_final_incremental_parity import clean, normalized, published +from tests.test_qt_signals_slots import SOURCE + + +NATIVE = SOURCE.replace( + "private slots:", "public slots:\n void active(int value) {}\nprivate slots:" +).replace("\n}\n", "\n receiver->active(5);\n}\n") +UNCHANGED = '''class Heartbeat : public QObject { Q_OBJECT +signals: void beat(); +public: void tick() { emit beat(); } +}; +''' +EVENT_KINDS = {"emission", "connect", "disconnect"} + + +def project(root): + sources = { + "events.cpp": NATIVE, + "unchanged.cpp": UNCHANGED, + # Keep ordinary unrelated declarations and a real call dependency while + # removing native events; a tiny-graph shrink guard is not the subject. + "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n" + + "\n".join(f"def keep_{index}(): return {index}" for index in range(20)), + } + for name, text in sources.items(): + (root / name).write_text(text, encoding="utf-8", newline="") + return root / "events.cpp" + + +def run(root, monkeypatch, operation, changes=None): + if operation == "manual": + # Production opt-out isolates installed-skill maintenance, without + # replacing any CLI, update, parser, resolver or persistence function. + monkeypatch.setenv("GRAPHIFY_NO_AUTO_REFRESH", "1") + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "update", str(root), "--no-cluster"]) + entrypoint.main() + else: + assert _rebuild_code(root, changed_paths=changes, no_cluster=True) + return published(root) + + +def source_facts(graph, files): + """Retain every public field and logical typed edge, including source spans.""" + nodes, edges = normalized(graph) + ids = {identity for identity, data in graph.nodes(data=True) if data.get("source_file") in files} + return ({identity: nodes[identity] for identity in ids}, + Counter({edge: count for edge, count in edges.items() if edge[0] in ids and edge[1] in ids})) + + +def event_sites(graph, file="events.cpp"): + return {identity: metadata for identity, data in graph.nodes(data=True) + if data.get("source_file") == file and (metadata := qt_metadata(data)).get("kind") in EVENT_KINDS} + + +def assert_distinct_mechanisms(graph, expected): + sites = event_sites(graph) + assert Counter(metadata["kind"] for metadata in sites.values()) == Counter(expected) + assert all(metadata["status"] == "resolved" for metadata in sites.values()) + assert all(metadata["runtime_delivery"] == "unverified" for metadata in sites.values() + if metadata["kind"] in {"connect", "disconnect"}) + # Event declarations reference source endpoints. They never manufacture a + # signal-to-receiver call or replace the direct public-slot invocation. + assert not any(data.get("_src", source) in sites and data.get("relation") == "calls" + for source, _, data in graph.edges(data=True)) + members = [metadata for _, data in graph.nodes(data=True) + if data.get("source_file") == "events.cpp" and (metadata := qt_metadata(data)).get("kind") == "member" + and metadata.get("raw_name") == "active"] + assert len(members) == 1 and members[0]["roles"] == ["slot"] + direct_target = members[0]["generic_target_id"] + # Ordinary untyped calls in a default undirected graph have no serialized + # logical-direction promise. Assert their actual canonical endpoint pair. + direct = [(target if source == direct_target else source, data) for source, target, data in graph.edges(data=True) + if direct_target in {source, target} and data.get("relation") == "calls"] + assert len(direct) == 1 and direct[0][0] not in sites + assert graph.nodes[direct[0][0]]["label"] == "wire()" + assert direct[0][1]["source_file"] == "events.cpp" + return sites + + +def compare_rebuild(graph, root, cache): + cold = clean(root, cache) + warm = clean(root, cache) + assert normalized(graph) == normalized(cold) == normalized(warm) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_qml016_ac04_event_mutation_and_removal_match_clean_rebuild_without_delivery_calls( + tmp_path, monkeypatch, operation +): + """Success: changed source facts; state transition: no stale event endpoints. + + Acceptance covers complete cold/warm parity and persistence, unchanged Qt + and Python source retention, and direct-slot/event relationship separation. + """ + source = project(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + compare_rebuild(initial, tmp_path, tmp_path / ".initial-clean") + initial_sites = assert_distinct_mechanisms(initial, {"emission": 3, "connect": 3, "disconnect": 1}) + untouched = source_facts(initial, {"unchanged.cpp", "keep.py"}) + assert untouched[0] and untouched[1] and len(event_sites(initial, "unchanged.cpp")) == 1 + + edited_text = NATIVE.replace("emit changed(1);", "Q_EMIT changed(11);").replace( + "Q_EMIT changed(2);", "changed(12);" + ).replace("Qt::QueuedConnection", "Qt::DirectConnection") + source.write_text(edited_text, encoding="utf-8", newline="") + edited = run(tmp_path, monkeypatch, operation, [source]) + compare_rebuild(edited, tmp_path, tmp_path / ".edited-clean") + edited_sites = assert_distinct_mechanisms(edited, {"emission": 3, "connect": 3, "disconnect": 1}) + assert sum(metadata["explicit_emit"] for metadata in edited_sites.values() if metadata["kind"] == "emission") == 1 + assert any(metadata.get("declared_type") == "DirectConnection" for metadata in edited_sites.values()) + assert not any(metadata.get("declared_type") == "QueuedConnection" for metadata in edited_sites.values()) + assert normalized(edited) != normalized(initial) + assert source_facts(edited, {"unchanged.cpp", "keep.py"}) == untouched + assert set(initial_sites) - set(edited_sites) + assert all(identity not in edited for identity in set(initial_sites) - set(edited_sites)) + + removed_text = edited_text.replace("Q_EMIT changed(11); changed(12); changed(3);", "") + removed_text = "\n".join(line for line in removed_text.splitlines() + if "QObject::connect(" not in line and "QObject::disconnect(" not in line) + "\n" + source.write_text(removed_text, encoding="utf-8", newline="") + removed = run(tmp_path, monkeypatch, operation, [source]) + compare_rebuild(removed, tmp_path, tmp_path / ".removed-clean") + assert_distinct_mechanisms(removed, {}) + assert all(identity not in removed for identity in edited_sites) + assert not any(data.get("source_file") == "events.cpp" and qt_metadata(data).get("kind") == "event_endpoint" + for _, data in removed.nodes(data=True)) + assert not any(data.get("source_file") == "events.cpp" and data.get("context", "").startswith( + ("qt_signal_emit", "qt_connect_", "qt_disconnect_") + ) for _, _, data in removed.edges(data=True)) + assert source_facts(removed, {"unchanged.cpp", "keep.py"}) == untouched + assert len(event_sites(removed, "unchanged.cpp")) == 1 diff --git a/tests/test_qt_events_boundaries.py b/tests/test_qt_events_boundaries.py new file mode 100644 index 0000000000..bbfec1170a --- /dev/null +++ b/tests/test_qt_events_boundaries.py @@ -0,0 +1,80 @@ +"""Native Qt event overload, callable, conditional and false-positive boundaries.""" +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_signals_slots import SOURCE + + +def test_functor_function_and_connection_handle_disconnect(tmp_path): + source = SOURCE + '''class Functor { public: void operator()(int value) {} }; +void consume(int value) {} +void extra(Sender *sender, Receiver *receiver) { + Functor callable; + QObject::connect(sender, &Sender::changed, receiver, callable); + QObject::connect(sender, &Sender::changed, &consume); + auto handle = QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept); + QObject::disconnect(handle); + QObject::disconnect(sender, nullptr, receiver, nullptr); +} +''' + result = analysis(tmp_path, {"events.cpp": source}) + connections = sites(result, "connect")[-3:] + assert all(qt_metadata(site)["status"] == "resolved" for site in connections), [qt_metadata(site) for site in connections] + disconnects = sites(result, "disconnect")[-2:] + assert all(qt_metadata(site)["status"] == "resolved" for site in disconnects) + assert qt_metadata(disconnects[0])["target_id"] == connections[-1]["id"] + assert qt_metadata(disconnects[1])["reason"] == "source_wildcard_disconnect" + + +def test_explicit_cast_signal_to_signal_and_condition_flags(tmp_path): + source = SOURCE + '''void extra(Sender *sender, Sender *other, bool enabled, Qt::ConnectionType mode) { + if (enabled) QObject::connect(sender, static_cast(&Sender::changed), other, &Sender::changed, Qt::DirectConnection | Qt::SingleShotConnection); + QObject::connect(sender, &Sender::changed, other, &Sender::changed, mode); +} +''' + result = analysis(tmp_path, {"events.cpp": source}) + first, second = sites(result, "connect")[-2:] + assert qt_metadata(first)["status"] == "resolved" and qt_metadata(first)["conditional"] + assert qt_metadata(first)["signal_target_id"] == qt_metadata(first)["receiver_target_id"] + assert qt_metadata(first)["flags"] == ["SingleShotConnection"] + assert qt_metadata(second)["dynamic_flags"] + endpoints = [node for node in sites(result, "event_endpoint") if qt_metadata(node)["owner_id"] == first["id"]] + assert len(endpoints) == 2 and endpoints[0]["id"] != endpoints[1]["id"] + + +def test_private_typed_pointer_and_incompatible_receiver_are_rejected(tmp_path): + source = SOURCE.replace("&Receiver::accept, Qt::QueuedConnection", "&Receiver::hidden, Qt::QueuedConnection") + result = analysis(tmp_path, {"events.cpp": source}) + assert qt_metadata(sites(result, "connect")[0])["status"] != "resolved" + incompatible = SOURCE.replace("void accept(int value)", "void accept(QString value)") + result2 = analysis(tmp_path, {"events.cpp": incompatible}) + assert qt_metadata(sites(result2, "connect")[0])["status"] != "resolved" + + +def test_computed_signal_receiver_and_custom_connect_are_not_qt_targets(tmp_path): + source = SOURCE.replace("emit changed(1);", "emit factory()->changed(1);") + result = analysis(tmp_path, {"events.cpp": source}) + assert qt_metadata(sites(result, "emission")[0])["status"] != "resolved" + custom = '''class Widget : public QObject { Q_OBJECT public: + void connect(QObject *a, int b, QObject *c, int d) {} + void run(QObject *a) { connect(a, 1, a, 2); } };''' + result2 = analysis(tmp_path, {"custom.cpp": custom}) + assert not sites(result2, "connect") + + +def test_const_reference_compatibility_does_not_allow_mutable_reference(tmp_path): + compatible = SOURCE.replace('void accept(int value)', 'void accept(const int &value)') + result = analysis(tmp_path, {"events.cpp": compatible}) + assert qt_metadata(sites(result, "connect")[0])["status"] == "resolved" + mutable = SOURCE.replace('void accept(int value)', 'void accept(int &value)') + result2 = analysis(tmp_path, {"events.cpp": mutable}) + assert qt_metadata(sites(result2, "connect")[0])["status"] != "resolved" + + +def test_direct_cpp_slot_call_remains_an_ordinary_call(tmp_path): + source = SOURCE.replace('private slots:', 'public slots:\n void active(int value) {}\nprivate slots:').replace('QObject::disconnect(sender, &Sender::changed, receiver, &Receiver::accept);', 'QObject::disconnect(sender, &Sender::changed, receiver, &Receiver::accept);\n receiver->active(5);') + result = analysis(tmp_path, {"events.cpp": source}) + active = [node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "active"][0] + target = qt_metadata(active)["generic_target_id"] + assert target + assert any(edge["target"] == target and edge["relation"] == "calls" for edge in result["edges"]) + assert len(sites(result, "connect")) == 3 and len(sites(result, "disconnect")) == 1 diff --git a/tests/test_qt_explicit_emission_updates.py b/tests/test_qt_explicit_emission_updates.py new file mode 100644 index 0000000000..03a6df0286 --- /dev/null +++ b/tests/test_qt_explicit_emission_updates.py @@ -0,0 +1,161 @@ +"""REQ-QML-016-AC04 explicit observations survive native declaration transitions.""" +from __future__ import annotations + +import os +import stat + +import pytest + +import graphify.watch as watch +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.test_qt_event_incremental_consumers import run +from tests.test_qt_final_incremental_parity import clean, normalized, unrelated +from tests.test_qt_product_publication import reject, snapshot + + +HEADER = '''class Backend : public QObject { Q_OBJECT +public: void run(); +signals: void missing(int value); +}; +''' +BODY = '''#include "events.h" +void Backend::run() { + QObject *admission_only; + emit missing(1); Q_EMIT missing(2); missing(3); +} +''' + + +def fixture(root): + """Separate declaration and event owners make header-only invalidation observable.""" + (root / "events.h").write_bytes(HEADER.encode()) + (root / "events.cpp").write_bytes(BODY.encode()) + (root / "keep.py").write_text("def helper(): return 7\n\ndef retained(): return helper()\n" + + "\n".join(f"def keep_{index}(): return {index}" for index in range(30)), encoding="utf-8") + return root / "events.h", root / "events.cpp" + + +def events(graph, *, resolved=True, count=None): + """Explicit observations retain exact identity; undeclared bare calls remain generic.""" + selected = {identity: qt_metadata(data) for identity, data in graph.nodes(data=True) + if qt_metadata(data).get("kind") == "emission"} + assert len(selected) == (count if count is not None else 3 if resolved else 2) + assert sum(md["explicit_emit"] for md in selected.values()) == (2 if count is None else count) + for identity, metadata in selected.items(): + assert metadata["owner_id"] in graph and graph.has_edge(metadata["owner_id"], identity) + assert metadata["status"] == ("resolved" if resolved else "unavailable") + if resolved: + assert metadata["target_id"] in graph + assert qt_metadata(graph.nodes[metadata["target_id"]])["raw_name"] == "missing" + else: + assert not metadata["target_id"] + assert not any(data.get("relation") == "calls" for _, _, data in graph.edges(identity, data=True)) + return selected + + +def parity(graph, root, name): + """Cold and warm production facades compare the complete public durable graph.""" + cache = root / (".clean-" + name) + assert normalized(graph) == normalized(clean(root, cache)) == normalized(clean(root, cache)) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml016_ac04_header_rename_remove_restore_and_marker_edits_preserve_observations(tmp_path, monkeypatch, operation): + """Header-only role removal retains explicit facts and removes their stale edges.""" + header, body = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + before = events(initial) + explicit = {identity for identity, md in before.items() if md["explicit_emit"]} + old_target = next(md["target_id"] for md in before.values()) + untouched = unrelated(initial) + parity(initial, tmp_path, "initial") + header.write_bytes(HEADER.replace("missing", "revised").encode()) + renamed = run(tmp_path, monkeypatch, operation, [header]) + assert set(events(renamed, resolved=False)) == explicit + assert old_target not in renamed + parity(renamed, tmp_path, "rename") + + removed_header = HEADER.replace("signals: void missing(int value);", "") + header.write_bytes(removed_header.encode()) + removed = run(tmp_path, monkeypatch, operation, [header]) + assert set(events(removed, resolved=False)) == explicit + parity(removed, tmp_path, "remove") + header.write_bytes(HEADER.encode()) + restored = run(tmp_path, monkeypatch, operation, [header]) + assert normalized(restored) == normalized(initial) + assert set(events(restored)) == set(before) + + header.write_bytes(removed_header.encode()) + run(tmp_path, monkeypatch, operation, [header]) + body.write_bytes(BODY.replace("emit missing(1);", "missing(1);").encode()) + one = run(tmp_path, monkeypatch, operation, [body]) + events(one, resolved=False, count=1) + parity(one, tmp_path, "one") + body.write_bytes(BODY.replace("emit ", "").replace("Q_EMIT ", "").encode()) + none = run(tmp_path, monkeypatch, operation, [body]) + events(none, resolved=False, count=0) + assert all(identity not in none for identity in explicit) + parity(none, tmp_path, "none") + assert unrelated(none) == untouched + accepted = snapshot(tmp_path / "graphify-out") + assert normalized(run(tmp_path, monkeypatch, operation, [header, body])) == normalized(none) + assert snapshot(tmp_path / "graphify-out") == accepted + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("failure", ["syntax", "publication"]) +def test_req_qml016_ac04_failed_explicit_refresh_retains_products_and_retries(tmp_path, monkeypatch, operation, failure): + """Actual extraction or late publication failure retains the accepted cohort.""" + header, _ = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + explicit = {identity for identity, md in events(initial).items() if md["explicit_emit"]} + output = tmp_path / "graphify-out" + accepted = snapshot(output) + if failure == "syntax": + header.write_bytes(b"class Backend { Q_OBJECT signals: void missing( ;\n") + reject(tmp_path, monkeypatch, operation, header) + else: + header.write_bytes(HEADER.replace("missing", "revised").encode()) + replace = watch.os_replace_with_fallback + + def fail_late(source, destination): + if destination.name == "manifest.json": + raise OSError("public fixture publication fault") + return replace(source, destination) + + with monkeypatch.context() as fault: + fault.setattr(watch, "os_replace_with_fallback", fail_late) + reject(tmp_path, fault, operation, header) + assert snapshot(output) == accepted + header.write_bytes(HEADER.replace("missing", "revised").encode()) + recovered = run(tmp_path, monkeypatch, operation, [header]) + assert set(events(recovered, resolved=False)) == explicit + parity(recovered, tmp_path, "recovered") + current = snapshot(output) + assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(recovered) + assert snapshot(output) == current + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary") +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml016_ac04_readonly_declaration_removal_preserves_graph_and_explicit_retry(tmp_path, monkeypatch, operation): + """A real OS denial leaves prior evidence until a corrected repeat publication.""" + header, _ = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + explicit = {identity for identity, md in events(initial).items() if md["explicit_emit"]} + output = tmp_path / "graphify-out" + before = snapshot(output) + header.write_bytes(HEADER.replace("signals: void missing(int value);", "").encode()) + target = output / "manifest.json" + target.chmod(stat.S_IREAD) + try: + reject(tmp_path, monkeypatch, operation, header) + assert snapshot(output) == before + finally: + target.chmod(stat.S_IWRITE) + recovered = run(tmp_path, monkeypatch, operation, [header]) + assert set(events(recovered, resolved=False)) == explicit + parity(recovered, tmp_path, "readonly-retry") diff --git a/tests/test_qt_explicit_emissions.py b/tests/test_qt_explicit_emissions.py new file mode 100644 index 0000000000..ffc824ba09 --- /dev/null +++ b/tests/test_qt_explicit_emissions.py @@ -0,0 +1,181 @@ +"""REQ-QML-016-AC01/AC04 explicit Qt syntax persists without declared signals.""" +import json + +import pytest + +from graphify.affected import affected_nodes +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import extract +from graphify.extractors.qt_cpp_calls import calls +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_mapping import map_cpp +from graphify.extractors.qt_cpp_syntax import read_cpp +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites + + +SOURCE = '''class Backend : public QObject { Q_OBJECT +public: void run() { emit missing(1); missing(2); Q_EMIT missing(3); helper(); } +void helper() {} +};''' + + +def test_req_qml016_ac01_explicit_unknown_sites_do_not_admit_bare_calls(tmp_path): + """Observed annotations are two source sites; the bare invocation stays generic.""" + result = analysis(tmp_path, {"events.cpp": SOURCE}) + emissions = sites(result, "emission") + assert len(emissions) == 2 + assert all(qt_metadata(node)["explicit_emit"] is True for node in emissions) + assert all(qt_metadata(node)["status"] == "unavailable" and not qt_metadata(node)["target_id"] for node in emissions) + original = (tmp_path / "events.cpp").read_bytes() + assert [original[md["span"]["start_byte"]:md["span"]["end_byte"]] + for node in emissions if (md := qt_metadata(node))] == [b"missing(1)", b"missing(3)"] + ids = {node["id"] for node in emissions} + assert not any(edge["source"] in ids and edge["relation"] == "calls" for edge in result["edges"]) + + +@pytest.mark.parametrize("marker", ["emit", "Q_EMIT"]) +def test_req_qml016_ac01_long_comment_crlf_bom_and_unicode_keep_original_spans(tmp_path, marker): + """Offset proof comes from accepted executable syntax and original disk bytes.""" + source = ("\ufeff// café 雪\nvoid run() { " + marker + " /* " + "reason " * 40 + + "*/\n object->missing(7); }\n").replace("\n", "\r\n") + result = analysis(tmp_path, {"events.cpp": source}) + emissions = sites(result, "emission") + assert len(emissions) == 1 + metadata = qt_metadata(emissions[0]) + assert metadata["explicit_emit"] and metadata["receiver_reference"] == "object" + assert metadata["status"] == "unavailable" and not metadata["target_id"] + assert (tmp_path / "events.cpp").read_bytes()[metadata["span"]["start_byte"]:metadata["span"]["end_byte"]] == b"object->missing(7)" + + +@pytest.mark.parametrize("body", [ + '// emit missing();\n/* Q_EMIT missing(); */\n', + 'const char *text = "emit missing()"; const char *raw = R"tag(Q_EMIT missing())tag";', + 'auto value = sizeof(emit missing());', + 'using Value = decltype(emit missing());', + 'auto value = noexcept(Q_EMIT missing());', + 'WRAP(emit missing());', + '#define OBSERVATION emit missing()\n', + 'myemit(missing()); Q_EMIT_EXTRA(missing());', +]) +def test_req_qml016_ac01_inert_macro_and_member_spellings_supply_no_explicit_role(tmp_path, body): + """Quoted, macro-owned or unevaluated names cannot create unknown Qt events.""" + result = analysis(tmp_path, {"events.cpp": "class Backend : public QObject { Q_OBJECT public: void run() {\n" + body + "\n} };"}) + assert not sites(result, "emission") + + +def test_req_qml016_ac01_unsupported_macro_member_normalization_reports_rejection(tmp_path): + """An existing Qt parse-view limitation remains explicit, without invented events.""" + path = tmp_path / "events.cpp" + path.write_text("class Backend : public QObject { Q_OBJECT public: void run() { object.emit(missing()); object.Q_EMIT(missing()); } };", encoding="utf-8") + result = extract([path], root=tmp_path, cache_root=tmp_path, parallel=False) + assert any(item["code"] == "QT_CPP_SYNTAX" for item in result["qml_failures"]) + assert not sites(result, "emission") + + +@pytest.mark.parametrize("marker", ["emit", "Q_EMIT"]) +def test_req_qml016_ac01_local_macro_override_cannot_inherit_qt_annotation_role(tmp_path, marker): + """A preceding define/undef blocks SDK annotation proof without preprocessing.""" + source = f"#define {marker} ordinary\nclass Backend : public QObject {{ Q_OBJECT public: void run() {{ {marker} missing(1); }} }};" + result = analysis(tmp_path, {"events.cpp": source}) + assert not sites(result, "emission") + + +def test_req_qml016_ac01_macro_override_order_preserves_prior_explicit_observation(tmp_path): + """Later source shadowing cannot revoke an earlier lexical annotation occurrence.""" + source = '''class Backend : public QObject { Q_OBJECT public: void run() { + emit missing(1); +#undef emit + emit missing(2); +} };''' + result = analysis(tmp_path, {"events.cpp": source}) + emitted = sites(result, "emission") + assert len(emitted) == 1 + metadata = qt_metadata(emitted[0]) + span = metadata["span"] + assert (tmp_path / "events.cpp").read_bytes()[span["start_byte"]:span["end_byte"]] == b"missing(1)" + + +def test_req_qml016_ac01_conditional_occurrence_keeps_uncertainty_without_delivery(tmp_path): + """Accepted conditional source is observed once without claiming execution.""" + source = SOURCE.replace("emit missing(1);", "if (ready) emit missing(1);") + result = analysis(tmp_path, {"events.cpp": source}) + emitted = sites(result, "emission") + assert len(emitted) == 2 + assert [qt_metadata(node)["conditional"] for node in emitted] == [True, False] + assert all(qt_metadata(node)["status"] == "unavailable" for node in emitted) + + +def test_req_qml016_ac01_declared_bare_inventory_still_observes_separate_occurrences(tmp_path): + """The opt-in preserves known bare signals and cannot duplicate explicit sites.""" + result = analysis(tmp_path, {"events.cpp": SOURCE.replace("public: void run()", "signals: void missing(int value); public: void run()")}) + emitted = sites(result, "emission") + assert len(emitted) == 3 and len({node["id"] for node in emitted}) == 3 + assert sum(qt_metadata(node)["explicit_emit"] for node in emitted) == 2 + assert all(qt_metadata(node)["status"] == "resolved" for node in emitted) + + +def test_req_qml016_ac01_default_call_collector_retains_existing_inventory_contract(tmp_path): + """The shared syntax scanner opts in explicitly; default callers see no extra names.""" + result = analysis(tmp_path, {"events.cpp": SOURCE}) + unit = read_cpp(tmp_path / "events.cpp", tmp_path) + mapping = map_cpp(unit, result["nodes"], result["edges"], root=tmp_path) + assert not list(calls(unit, mapping, {"unrelated"})) + admitted = list(calls(unit, mapping, {"unrelated"}, include_explicit=True)) + assert len(admitted) == 2 and all(item["explicit_emit"] for item in admitted) + + +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml016_ac04_unknown_emissions_reload_as_owned_unresolved_sites(tmp_path, directed): + """New facts preserve callable containment, independent of an absent endpoint.""" + result = analysis(tmp_path, {"events.cpp": SOURCE}) + graph = build_from_json(result, root=tmp_path, directed=directed) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path)) + reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + emissions = sites(result, "emission") + assert len(emissions) == 2 + for node in emissions: + metadata = qt_metadata(node) + assert qt_metadata(reloaded.nodes[node["id"]]) == metadata + assert metadata["owner_id"] in reloaded and reloaded.has_edge(metadata["owner_id"], node["id"]) + assert metadata["owner_id"] in {hit.node_id for hit in affected_nodes(reloaded, node["id"], relations=["contains"], depth=1)} + assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(node["id"], data=True)) + + +@pytest.mark.parametrize("name,marker", [("connect", "emit"), ("disconnect", "Q_EMIT")]) +@pytest.mark.parametrize("declared", [False, True]) +def test_req_qml016_ac01_explicit_annotation_owns_emission_mechanism(tmp_path, name, marker, declared): + """An explicit annotation takes precedence over a same-spelled Qt connection API.""" + declaration = f"signals: void {name}();" if declared else "" + source = f"class Backend : public QObject {{ Q_OBJECT {declaration} public: void run() {{ {marker} {name}(); }} }};" + result = analysis(tmp_path, {"events.cpp": source}) + selected = sites(result, "emission") + assert len(selected) == 1, [(qt_metadata(n).get("kind"), qt_metadata(n).get("raw_name")) for n in result["nodes"] if qt_metadata(n)] + md = qt_metadata(selected[0]) + assert md["explicit_emit"] is True + assert md["status"] == ("resolved" if declared else "unavailable") + + +@pytest.mark.parametrize("marker", ["emit", "Q_EMIT"]) +def test_req_qml016_ac01_override_cannot_recover_annotation_by_prefix(tmp_path, marker): + """Known bare signal admission cannot reauthorize a rejected annotation token.""" + source = f"#define {marker} ordinary\nclass Backend : public QObject {{ Q_OBJECT signals: void ready(); public: void run() {{ {marker} ready(); }} }};" + result = analysis(tmp_path, {"events.cpp": source}) + selected = sites(result, "emission") + assert len(selected) == 1 + assert qt_metadata(selected[0])["explicit_emit"] is False + + +@pytest.mark.parametrize("marker", ["emit", "Q_EMIT"]) +def test_req_qml016_ac01_computed_receiver_keeps_full_explicit_source_site(tmp_path, marker): + """An outer annotated call cannot reclassify its nested factory as a bare signal.""" + source = f"class Backend : public QObject {{ Q_OBJECT public: void run() {{ {marker} factory()->missing(1); }} }};" + result = analysis(tmp_path, {"events.cpp": source}) + selected = sites(result, "emission") + assert len(selected) == 1 + md = qt_metadata(selected[0]) + assert md["raw_name"] == "missing" and md["explicit_emit"] is True + assert md["status"] == "unavailable" and not md["target_id"] + assert (tmp_path / "events.cpp").read_bytes()[md["span"]["start_byte"]:md["span"]["end_byte"]] == b"factory()->missing(1)" diff --git a/tests/test_qt_export_matrix.py b/tests/test_qt_export_matrix.py new file mode 100644 index 0000000000..f6bc9663d9 --- /dev/null +++ b/tests/test_qt_export_matrix.py @@ -0,0 +1,153 @@ +"""Qt semantic exports versus explicit presentation-only format boundaries.""" +from __future__ import annotations + +import json +import re +import sys +from types import SimpleNamespace + +import networkx as nx +import pytest + +from graphify.build import build_from_json +from graphify.export import to_canvas, to_cypher, to_obsidian, to_svg +from graphify.exporters.graphdb import push_to_falkordb, push_to_neo4j +from graphify.qt_export import qt_cypher_lines +from tests.test_qt_qml_integration import corpus + + +def actual_graph(root): + result = corpus(root) + return result, build_from_json(result, root=root) + + +def test_actual_cypher_file_retains_metadata_sources_and_native_call_direction(tmp_path): + result, graph = actual_graph(tmp_path) + output = tmp_path / "native.cypher" + to_cypher(graph, str(output)) + text = output.read_text(encoding="utf-8") + serialized = re.findall(r'`metadata_json`:("(?:[^"\\]|\\.)*")', text) + restored = [json.loads(json.loads(value)) for value in serialized] + expected = [data["metadata"] for _, data in graph.nodes(data=True) if data.get("metadata")] + expected += [data["metadata"] for _, _, data in graph.edges(data=True) if data.get("metadata")] + assert sorted(map(lambda value: json.dumps(value, sort_keys=True), restored)) == sorted( + map(lambda value: json.dumps(value, sort_keys=True), expected)) + call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call") + match = f'MATCH (a {{id:{json.dumps(call["source"])}}}), (b {{id:{json.dumps(call["target"])}}})' + assert match in text + assert "`source_location`:" in text and "`context`:" in text and "`confidence`:" in text + assert "metadata_json" in text and "graphify_fact_key" in text + + +@pytest.mark.parametrize("backend", ["neo4j", "falkordb"]) +def test_actual_push_driver_boundary_keeps_native_metadata_and_logical_direction(tmp_path, monkeypatch, backend): + result, graph = actual_graph(tmp_path) + calls = [] + + class Session: + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def run(self, query, **params): + calls.append((query, params)) + + def query(self, query, params=None): + calls.append((query, params or {})) + + if backend == "neo4j": + driver = SimpleNamespace(session=Session, close=lambda: None) + module = SimpleNamespace(GraphDatabase=SimpleNamespace(driver=lambda *_, **__: driver)) + monkeypatch.setitem(sys.modules, "neo4j", module) + pushed = push_to_neo4j(graph, "unused", "", "") + else: + client = SimpleNamespace(select_graph=lambda _: Session()) + monkeypatch.setitem(sys.modules, "falkordb", SimpleNamespace(FalkorDB=lambda **_: client)) + pushed = push_to_falkordb(graph, "unused") + assert pushed == {"nodes": len(graph), "edges": graph.number_of_edges()} + node_props = {params["id"]: params["props"] for query, params in calls if query.startswith("MERGE (n:")} + for node_id, data in graph.nodes(data=True): + assert node_props[node_id].get("source_file") == data.get("source_file") + if data.get("metadata"): + assert json.loads(node_props[node_id]["metadata_json"]) == data["metadata"] + exported = {(params["src"], params["tgt"]): params["props"] + for query, params in calls if query.startswith("MATCH")} + call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call") + props = exported[(call["source"], call["target"])] + assert props["context"] == "qml_js_call" and props["confidence"] == "INFERRED" + assert json.loads(props["metadata_json"]) == graph.edges[call["source"], call["target"]]["metadata"] + assert props["graphify_source"] == call["source"] and props["graphify_target"] == call["target"] + assert all("fact_key" in params for query, params in calls if query.startswith("MATCH")) + + +def test_cypher_keeps_parallel_mechanisms_and_escapes_literal_data(tmp_path): + _, base = actual_graph(tmp_path) + graph = nx.MultiDiGraph(base) + graph.add_edge("extra", "second", relation="references", context="qt_connect_signal", confidence="INFERRED") + graph.add_edge("extra", "second", relation="references", context="qt_disconnect_signal", confidence="INFERRED") + graph.nodes["extra"].update(label='quote"\\\n') + to_html(poisoned, {0: list(poisoned)}, str(path)) + content = path.read_text(encoding="utf-8") + assert '' not in content + info2 = runtime_info(content, connection["id"], monkeypatch) + assert "<script>alert(1)</script>" in info2 and "" not in info2 + + +def test_qml013_ac03_existing_search_namespace_and_generic_payload_are_preserved(tmp_path): + """Public user attributes win; ordinary HTML nodes retain their prior schema.""" + result = analysis(tmp_path, {"events.cpp": SOURCE, "plain.py": 'def ordinary():\n return 1\n'}) + graph = build_from_json(result, root=tmp_path) + connection = sites(result, "connect")[0] + graph.nodes[connection["id"]]["attributes"] = {SEARCH_NAMESPACE: {"custom": "owned"}, "keep": 1} + path = tmp_path / "graph.html" + to_html(graph, {0: list(graph)}, str(path)) + nodes = {node["id"]: node for node in payload(path.read_text(encoding="utf-8"), "RAW_NODES")} + assert nodes[connection["id"]]["attributes"] == {SEARCH_NAMESPACE: {"custom": "owned"}, "keep": 1} + assert "qt_qml" not in nodes[connection["id"]] + generic = next(node for node in nodes.values() if node.get("source_file") == "plain.py") + assert "metadata" not in generic and "attributes" not in generic and "source_location" not in generic + + +def test_qml013_ac03_aggregated_html_explicitly_states_source_fact_omission(tmp_path): + """An aggregate artifact cannot imply that its two communities retain facts.""" + result = analysis(tmp_path, {"events.cpp": SOURCE}) + graph = build_from_json(result, root=tmp_path) + nodes = list(graph) + communities = {0: nodes[:len(nodes) // 2], 1: nodes[len(nodes) // 2:]} + before = copy.deepcopy(graph.graph) + path = tmp_path / "aggregate.html" + assert to_html(graph, communities, str(path), node_limit=2) + content = path.read_text(encoding="utf-8") + assert len(payload(content, "RAW_NODES")) == 2 + assert "Qt/QML source details are omitted in this community view" in content + assert graph.graph == before diff --git a/tests/test_qt_graph_persistence.py b/tests/test_qt_graph_persistence.py new file mode 100644 index 0000000000..d2b9f34858 --- /dev/null +++ b/tests/test_qt_graph_persistence.py @@ -0,0 +1,55 @@ +"""Qt occurrence identities and bridge direction through actual graph/export/reload.""" +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_access_providers import BACKEND +from tests.test_qt_signals_slots import SOURCE + + +@pytest.mark.parametrize("directed", [False, True]) +def test_native_repeated_sites_export_with_original_utf8_crlf_spans(tmp_path, directed): + source = ("// Unicode café\n" + SOURCE).replace("\n", "\r\n") + result = analysis(tmp_path, {"events.cpp": source}) + graph = build_from_json(result, root=tmp_path, directed=directed) + output = tmp_path / "graph.json" + assert to_json(graph, {}, str(output), force=True) + reloaded = load_node_link_graph(output) + for site in [*sites(result, "emission"), *sites(result, "connect"), *sites(result, "disconnect")]: + assert site["id"] in reloaded + metadata = qt_metadata(reloaded.nodes[site["id"]]) + span = metadata["span"] + fragment = source.encode()[span["start_byte"]:span["end_byte"]] + assert fragment and b"(" in fragment + endpoints = sites(result, "event_endpoint") + assert len(endpoints) == 8 + for node in endpoints: + target = qt_metadata(node)["target_id"] + assert reloaded.has_edge(node["id"], target) + assert not any(attrs.get("relation") == "calls" and attrs.get("context", "").startswith("qt_connect") for _, _, attrs in reloaded.edges(data=True)) + + +@pytest.mark.parametrize("directed", [False, True]) +def test_cpp_qml_context_calls_and_explicit_access_survive_build_export(tmp_path, directed): + url = (tmp_path / "Main.qml").as_uri() + source = BACKEND + f'''void use(Backend *backend) {{ QQuickView view; + view.setInitialProperties({{{{"backend", backend}}}}); view.setSource(QUrl("{url}")); + auto root = view.rootObject(); QMetaObject::invokeMethod(root, "refresh"); + QObject::connect(root, SIGNAL(closed()), backend, SLOT(close())); }}''' + qml = 'import QtQml\nQtObject { property var backend; function refresh() { backend.refresh() } signal closed() }' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml}) + graph = build_from_json(result, root=tmp_path, directed=directed) + output = tmp_path / "graph.json" + assert to_json(graph, {}, str(output), force=True) + reloaded = load_node_link_graph(output) + for node in [*sites(result, "qml_load"), *sites(result, "qml_root"), *sites(result, "qml_access"), *sites(result, "context_access"), *sites(result, "event_endpoint")]: + metadata = qt_metadata(node) + assert metadata["status"] == "resolved" + assert reloaded.has_edge(node["id"], metadata["target_id"]), (metadata["kind"], metadata["target_id"]) + stored = json.loads(output.read_text(encoding="utf-8")) + assert stored["nodes"] diff --git a/tests/test_qt_header_adoption.py b/tests/test_qt_header_adoption.py new file mode 100644 index 0000000000..6e1a132468 --- /dev/null +++ b/tests/test_qt_header_adoption.py @@ -0,0 +1,69 @@ +"""REQ-QML-018-AC07: lexical header admission and original source authority.""" +from __future__ import annotations + +import pytest + +from graphify.extract import _get_extractor, extract, extract_c, extract_cpp, extract_objc +from graphify.extractors.qt_cpp_facts import qt_metadata + + +@pytest.mark.parametrize("separator", ["\n", "\t", "\r\n", " /* comment */ "]) +@pytest.mark.parametrize("prefix", ["", "\ufeff// λ\r\n"]) +def test_req_qml018_ac07_whitespace_header_uses_cpp_source_authority(tmp_path, separator, prefix): + """C++ class keywords survive whitespace while authoritative bytes stay original.""" + header = tmp_path / "backend.h" + source = prefix + "class" + separator + "Backend : public QObject { Q_OBJECT };\r\n" + header.write_bytes(source.encode("utf-8")) + assert _get_extractor(header) is extract_cpp + result = extract([header], root=tmp_path, cache_root=tmp_path, parallel=False) + assert not result.get("qml_failures") + classes = [node for node in result["nodes"] if qt_metadata(node).get("kind") == "class"] + assert len(classes) == 1 + metadata = qt_metadata(classes[0]) + assert metadata["status"] == "resolved" and metadata["generic_target_id"] + span = metadata["span"] + assert header.read_bytes()[span["start_byte"]:span["end_byte"]].startswith(b"class") + + +@pytest.mark.parametrize("source", [ + '/* class Fake; namespace Ghost { } template public: :: */\nint value(void);', + '// class Ghost;\nconst char *label = "namespace Fake { :: public:";\n', + 'const char *label = "class Fake";\nstruct Backend { int value; };\n', + "struct Backend { int value; };\nint inspect(struct Backend *value);\n", + 'const char *label = R"mark(class Fake; namespace Shadow { })mark";\n', + '// continued comment \\ \n class Fake;\nint value;'.replace("\\ ", "\\"), + 'const char *label = "escaped \\" class Fake";\n', +]) +def test_req_qml018_ac07_inert_cpp_markers_keep_c_dispatch(tmp_path, source): + """Quoted/commented keywords cannot authorize a parser change for C inputs.""" + header = tmp_path / "plain.h" + header.write_bytes(source.encode()) + assert _get_extractor(header) is extract_c + + +@pytest.mark.parametrize("source", [ + "struct Backend final { int value; };\n", + "namespace\nPublic { struct Backend { int value; }; }\n", + "template\n struct Backend { Value value; };\n", +]) +def test_req_qml018_ac07_additional_cpp_declarations(tmp_path, source): + """Supported C++ declaration tokens select C++ without evaluating build metadata.""" + header = tmp_path / "native.h" + header.write_bytes(source.encode()) + assert _get_extractor(header) is extract_cpp + + +def test_req_qml018_ac07_objective_c_retains_dispatch_priority(tmp_path): + """The established Objective-C boundary wins when both source forms are present.""" + header = tmp_path / "view.h" + header.write_bytes(b"@interface View\n@end\nclass Backend {};\n") + assert _get_extractor(header) is extract_objc + + +def test_req_qml018_ac07_header_prefix_budget_and_digit_separators(tmp_path): + """Markers outside the existing 256 KiB budget remain inconclusive; digits are lexical tokens.""" + header = tmp_path / "large.h" + header.write_bytes(b" " * (256 * 1024) + b"class Backend {};\n") + assert _get_extractor(header) is extract_c + header.write_bytes(b"const long value = 1'000;\nclass\nBackend {};\n") + assert _get_extractor(header) is extract_cpp diff --git a/tests/test_qt_header_updates.py b/tests/test_qt_header_updates.py new file mode 100644 index 0000000000..be33b20d06 --- /dev/null +++ b/tests/test_qt_header_updates.py @@ -0,0 +1,75 @@ +"""REQ-QML-018-AC07/AC06: actual header dispatch update and durable failure.""" +from __future__ import annotations + +import os +import stat + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.test_qt_event_incremental_consumers import run +from tests.test_qt_final_incremental_parity import clean, normalized +from tests.test_qt_product_publication import reject, snapshot + +CPP = "\ufeff// λ\r\nclass\r\nBackend : public QObject { Q_OBJECT };\r\n" +C = '/* class Ghost {}; :: namespace Fake {} */\nstruct Backend { int value; };\n' + + +def setup(root): + """Generic retained sources prevent unrelated shrink policy from obscuring dispatch.""" + header = root / "backend.h" + header.write_bytes(CPP.encode("utf-8")) + (root / "keep.py").write_text("\n".join(f"def keep_{i}(): return {i}" for i in range(30)), encoding="utf-8") + return header + + +def cpp_targets(graph): + """Only an accepted canonical Qt class proves C++ header extraction occurred.""" + return [qt_metadata(node)["generic_target_id"] for _, node in graph.nodes(data=True) + if node.get("source_file") == "backend.h" and qt_metadata(node).get("kind") == "class" + and qt_metadata(node).get("status") == "resolved"] + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac07_header_dispatch_mutation_matches_cold_and_warm(tmp_path, monkeypatch, operation): + """C++→C→C++ edits replace old authority without stale classes or cache aliases.""" + header = setup(tmp_path) + monkeypatch.chdir(tmp_path) + original = run(tmp_path, monkeypatch, operation) + old = cpp_targets(original) + assert len(old) == 1 and old[0] in original + header.write_bytes(C.encode()) + plain = run(tmp_path, monkeypatch, operation, [header]) + assert not cpp_targets(plain) + assert not any(node.get("metadata", {}).get("cpp_class") for _, node in plain.nodes(data=True) + if node.get("source_file") == "backend.h") + assert normalized(plain) == normalized(clean(tmp_path, tmp_path / ".clean-c")) + header.write_bytes(CPP.encode("utf-8")) + restored = run(tmp_path, monkeypatch, operation, [header]) + assert normalized(restored) == normalized(original) + assert normalized(restored) == normalized(clean(tmp_path, tmp_path / ".clean-cpp")) + accepted = snapshot(tmp_path / "graphify-out") + assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(restored) + assert snapshot(tmp_path / "graphify-out") == accepted + + +@pytest.mark.skipif(os.name != "nt", reason="Real Windows read-only publication boundary") +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac06_header_refresh_denial_preserves_then_recovers(tmp_path, monkeypatch, operation): + """Actual final sidecar denial retains the accepted cohort after dispatch changed.""" + header = setup(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out" + before = snapshot(output) + header.write_bytes(C.encode()) + destination = output / "manifest.json" + destination.chmod(stat.S_IREAD) + try: + reject(tmp_path, monkeypatch, operation, header) + assert snapshot(output) == before + finally: + destination.chmod(stat.S_IWRITE) + repaired = run(tmp_path, monkeypatch, operation, [header]) + assert not cpp_targets(repaired) and normalized(repaired) != normalized(initial) + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".clean-recovery")) diff --git a/tests/test_qt_html_consumers.py b/tests/test_qt_html_consumers.py new file mode 100644 index 0000000000..b5cce6345d --- /dev/null +++ b/tests/test_qt_html_consumers.py @@ -0,0 +1,54 @@ +"""QML-013-AC03 Qt event links cannot disappear or look like runtime calls.""" +import json + +import pytest + +from graphify.build import build_from_json +from graphify.callflow_html import preferred_edges, generate_call_table_rows +from graphify.callflow_html import write_callflow_html +from graphify.export import to_json +from graphify.qt_relationship_views import render_relationship_table +from tests.qt_analysis_helpers import analysis +from tests.test_qt_signals_slots import SOURCE + + +def test_html_keeps_native_connections_and_subscriptions_with_ordinary_calls(tmp_path): + result = analysis(tmp_path, {"events.cpp": SOURCE}) + connection = next(edge for edge in result["edges"] if edge.get("context") == "qt_connect_signal") + assert connection in preferred_edges(result["edges"]) + table = render_relationship_table(result["nodes"], result["edges"]) + assert 'connection signal' in table and 'signal emission' in table and 'runtime delivery is unverified' in table + assert 'events.cpp:' in table and 'INFERRED' in table + + +def test_signal_emission_is_not_rendered_as_a_caller_in_the_call_table(tmp_path): + result = analysis(tmp_path, {"events.cpp": SOURCE}) + emission = next(edge for edge in result["edges"] if edge.get("context") == "qt_signal_emit") + target = next(node for node in result["nodes"] if node["id"] == emission["target"]) + rows = generate_call_table_rows([target], [emission], "en", [emission], result["nodes"]) + assert "Qt emission:" not in rows + + +@pytest.mark.parametrize("directed", [False, True], ids=["default-graph", "directed-graph"]) +def test_qml013_ac03_written_html_keeps_source_event_evidence_and_escapes_labels(tmp_path, directed): + """The saved HTML artifact exposes facts, source spans and uncertainty safely.""" + result = analysis(tmp_path, {"events.cpp": SOURCE}) + graph = build_from_json(result, root=tmp_path, directed=directed) + connection = next(edge for edge in result["edges"] if edge.get("context") == "qt_connect_signal") + for _, node in graph.nodes(data=True): + node["community"] = 0 + graph.nodes[connection["source"]]["label"] = '' + output = tmp_path / "graphify-out" + output.mkdir(exist_ok=True) + assert to_json(graph, {}, str(output / "graph.json"), force=True) + sections = tmp_path / "sections.json" + sections.write_text(json.dumps([{"id": "qt", "name": "Qt facts", "communities": [0]}]), encoding="utf-8") + artifact = write_callflow_html(tmp_path, sections=sections, output="graphify-out/events.html", lang="en") + html = artifact.read_text(encoding="utf-8") + assert 'class="qt-qml-relationships"' in html + assert 'data-source-id="' + connection["source"] + '"' in html + assert 'data-target-id="' + connection["target"] + '"' in html + assert "connection signal" in html and "signal emission" in html + assert "events.cpp:L" in html and "INFERRED" in html + assert "runtime delivery is unverified" in html + assert "<script>alert(1)</script>" in html and '' not in html diff --git a/tests/test_qt_incremental_policy.py b/tests/test_qt_incremental_policy.py new file mode 100644 index 0000000000..a088885d3e --- /dev/null +++ b/tests/test_qt_incremental_policy.py @@ -0,0 +1,78 @@ +"""QML-011: accepted-input refresh, provider removal and cache/config invariants.""" +import pytest + +from graphify.qt_incremental import ( + is_qt_metadata, plan_qt_refresh, qt_analysis_fingerprint, qt_syntax_cache_bypass, +) + + +@pytest.mark.parametrize("path", ["qmldir", "nested/CMakeLists.txt", "module.cmake", + "tools.pro", "settings.pri", "types.qmltypes", "ui.QRC"]) +def test_every_declared_qt_metadata_form_invalidates_unchanged_consumers(path): + plan = plan_qt_refresh(["Main.qml", "backend.h", path], [path]) + assert plan.required + assert plan.accepted_inputs == tuple(sorted(["Main.qml", "backend.h", path])) + assert qt_syntax_cache_bypass(path) + assert is_qt_metadata(path) + + +@pytest.mark.parametrize("path", ["qmldir.txt", "QMLDIR", "cmakelists.txt", "script.py", "notes.txt"]) +def test_unrelated_files_do_not_become_qt_metadata_or_force_scope_rebuilds(path): + assert not is_qt_metadata(path) + assert not qt_syntax_cache_bypass(path) + assert not plan_qt_refresh(["Main.qml", path], [path]).required + + +@pytest.mark.parametrize("path", ["backend.cpp", "backend.h", "backend.hpp", "backend.cxx"]) +def test_native_provider_only_updates_include_unchanged_qml_and_cpp_consumers(path): + plan = plan_qt_refresh(["Main.qml", "consumer.cpp", path], [path]) + assert plan.required + assert "Main.qml" in plan.accepted_inputs + assert "qt_native_provider_changed" in plan.reasons + assert not qt_syntax_cache_bypass(path) + assert qt_syntax_cache_bypass(path, native=True) + + +def test_last_provider_deletion_and_rename_never_read_removed_or_ignored_sources(): + prior = ["Main.qml", "backend.h", "resources.qrc"] + plan = plan_qt_refresh(["consumer.cpp", "new.h"], ["backend.h", "Main.qml", "resources.qrc", "new.h"], + prior_paths=prior) + assert plan.required + assert plan.accepted_inputs == ("consumer.cpp", "new.h") + assert "backend.h" not in plan.accepted_inputs + assert "resources.qrc" not in plan.accepted_inputs + assert plan_qt_refresh(["consumer.py"], ["last_native.h"]).required + + +def test_ignore_and_analysis_config_changes_refresh_only_new_accepted_corpus(): + plan = plan_qt_refresh(["Main.qml"], [".graphifyignore"], prior_paths=["backend.h", "Main.qml"]) + assert plan.required and plan.accepted_inputs == ("Main.qml",) + assert "qt_corpus_policy_changed" in plan.reasons + assert plan_qt_refresh(["Main.qml"], [], configuration_changed=True).required + assert not plan_qt_refresh(["app.py"], [], configuration_changed=True).required + + +def test_new_native_qt_events_are_not_hidden_by_an_ordinary_cpp_context(): + plan = plan_qt_refresh(["ordinary.cpp", "consumer.cpp"], ["ordinary.cpp"]) + assert plan.required + assert plan.accepted_inputs == ("consumer.cpp", "ordinary.cpp") + + +def test_shared_js_refreshes_qt_consumers_only_when_a_qt_context_exists(): + assert plan_qt_refresh(["Main.qml", "utils.mjs"], ["utils.mjs"]).required + assert not plan_qt_refresh(["app.js"], ["app.js"]).required + assert plan_qt_refresh(["utils.js"], ["utils.js"], qt_facts_present=True).required + + +def test_configuration_fingerprint_covers_parser_contract_import_order_and_ignores(): + base = {"parser_version": "qmljs-test-1", "fact_version": 1, + "import_roots": ["imports", "."], "ignore_patterns": ["build/", "generated/"]} + original = qt_analysis_fingerprint(**base) + assert qt_analysis_fingerprint(**base) == original + for changed in ({"parser_version": "qmljs-test-2"}, {"fact_version": 2}, + {"import_roots": [".", "imports"]}, {"ignore_patterns": ["build/"]}, + {"ignore_patterns": list(reversed(base["ignore_patterns"]))}, + {"profile": "qt6-reviewed-next"}): + assert qt_analysis_fingerprint(**{**base, **changed}) != original + with pytest.raises(ValueError, match="QT_CONFIG"): + qt_analysis_fingerprint(parser_version="test", fact_version=0) diff --git a/tests/test_qt_inherited_access.py b/tests/test_qt_inherited_access.py new file mode 100644 index 0000000000..b6099b3ecd --- /dev/null +++ b/tests/test_qt_inherited_access.py @@ -0,0 +1,76 @@ +"""REQ-QML-016-AC01 inheritance access is source proof, separate from meta lookup.""" +import copy + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt +from graphify.qt_event_index import QtEventIndex +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_inherited_endpoints import SOURCE + + +@pytest.mark.parametrize("access", ["private", "protected", "default_class"]) +def test_req_qml016_ac01_external_member_pointer_cannot_cross_nonpublic_base(tmp_path, access): + """External C++ pointers cannot acquire public access through private/protected bases.""" + spelling = "" if access == "default_class" else access + " " + source = SOURCE.replace("class Middle : public Grand", "class Middle : " + spelling + "Grand") + result = analysis(tmp_path, {"events.cpp": source}) + modern, legacy = sites(result, "connect") + assert qt_metadata(modern)["signal_status"] == "unavailable" + assert qt_metadata(modern)["receiver_status"] == "unavailable" + # QMetaObject/legacy access and an owning-class emission do not manufacture + # a C++ pointer conversion. Their previously admitted mechanism is preserved. + assert qt_metadata(legacy)["status"] == "resolved" + assert qt_metadata(sites(result, "emission")[0])["status"] == "resolved" + + +def test_req_qml016_ac01_explicit_grandparent_pointer_requires_public_object_conversion(tmp_path): + """Qualifying Grand directly does not bypass Child's private inherited base.""" + source = SOURCE.replace("class Middle : public Grand", "class Middle : private Grand") + source = source.replace("&Child::changed", "&Grand::changed").replace("&Child::accept", "&Grand::accept") + result = analysis(tmp_path, {"events.cpp": source}) + assert qt_metadata(sites(result, "connect")[0])["signal_status"] == "unavailable" + assert qt_metadata(sites(result, "connect")[0])["signal_reason"] == "inheritance_access_unavailable" + + +def test_req_qml016_ac01_struct_default_and_virtual_access_are_captured_in_source_order(tmp_path): + """Each comma resets default access; virtual does not replace explicit access.""" + source = SOURCE.replace("class Middle : public Grand { Q_OBJECT };", '''class Empty { }; +struct Middle : Grand, virtual public Empty { Q_OBJECT };''') + result = analysis(tmp_path, {"events.cpp": source}) + middle = next(node for node in sites(result, "class") if qt_metadata(node)["class_name"] == "Middle") + assert qt_metadata(middle)["canonical_base_access"] == ["public", "public"] + assert qt_metadata(middle)["canonical_base_access_status"] == "resolved" + assert qt_metadata(sites(result, "connect")[0])["status"] == "resolved" + + +@pytest.mark.parametrize("invalid", ["missing", "count", "value", "shape", "status"]) +def test_req_qml016_ac01_missing_or_corrupt_base_access_rejects_pointer_only(tmp_path, invalid): + """Old or corrupt accepted facts cannot supply access authority from a default.""" + result = analysis(tmp_path, {"events.cpp": SOURCE}) + nodes = copy.deepcopy(result["nodes"]) + middle = next(node for node in nodes if qt_metadata(node).get("kind") == "class" + and qt_metadata(node)["class_name"] == "Middle") + update_qt(middle, **{"missing": {"canonical_base_access": None}, + "count": {"canonical_base_access": []}, + "value": {"canonical_base_access": ["invented"]}, + "shape": {"canonical_base_access": [{}]}, + "status": {"canonical_base_access_status": "unavailable"}}[invalid]) + before = copy.deepcopy(nodes) + index = QtEventIndex(nodes) + endpoint = {"form": "member_pointer", "class_name": "Child", "member_name": "changed"} + result = index.member(endpoint, "Child", role="signal") + assert result.status == "unavailable" and result.reason == "inheritance_access_unavailable" + assert index.member({**endpoint, "form": "legacy"}, "Child", role="signal").status == "resolved" + assert nodes == before + + +def test_req_qml016_ac01_class_default_resets_at_comma_and_comments_supply_no_authority(tmp_path): + """A private empty mixin does not hide Grand's public declaration or carry its access.""" + source = SOURCE.replace("class Middle : public Grand { Q_OBJECT };", '''class Empty { }; +class Middle : public /* documented base */ Grand, Empty { Q_OBJECT };''') + result = analysis(tmp_path, {"events.cpp": source}) + middle = next(node for node in sites(result, "class") if qt_metadata(node)["class_name"] == "Middle") + assert qt_metadata(middle)["canonical_base_names"] == ["Grand", "Empty"] + assert qt_metadata(middle)["canonical_base_access"] == ["public", "private"] + assert qt_metadata(sites(result, "connect")[0])["status"] == "resolved" diff --git a/tests/test_qt_inherited_endpoints.py b/tests/test_qt_inherited_endpoints.py new file mode 100644 index 0000000000..9e6ab95f54 --- /dev/null +++ b/tests/test_qt_inherited_endpoints.py @@ -0,0 +1,221 @@ +"""REQ-QML-016-AC01/AC04 accepted ancestral event endpoint identities.""" +import json +import copy + +import pytest + +from graphify.affected import affected_nodes +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt +from graphify.paths import load_node_link_graph +from graphify.qt_event_index import QtEventIndex +from graphify.serve import _query_graph_text +from tests.qt_analysis_helpers import analysis, sites + + +SOURCE = '''class Grand : public QObject { Q_OBJECT +signals: void changed(int value); +public slots: void accept(int value) {} +}; +class Middle : public Grand { Q_OBJECT }; +class Child : public Middle { Q_OBJECT +public: void run() { emit changed(1); } +}; +void wire(Child *sender, Child *receiver) { + QObject::connect(sender, &Child::changed, receiver, &Child::accept); + QObject::connect(sender, SIGNAL(changed(int)), receiver, SLOT(accept(int))); + QObject::disconnect(sender, &Child::changed, receiver, &Child::accept); +} +''' + + +def test_req_qml016_ac01_grandparent_endpoints_use_declaring_members(tmp_path): + """A three-level class has one ancestor signal and receiver declaration.""" + result = analysis(tmp_path, {"events.cpp": SOURCE}) + emission = sites(result, "emission")[0] + signal = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "changed") + receiver = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "accept") + assert qt_metadata(emission)["status"] == "resolved" + assert qt_metadata(emission)["target_id"] == signal["id"] + for event in sites(result, "connect") + sites(result, "disconnect"): + assert qt_metadata(event)["status"] == "resolved" + assert qt_metadata(event)["signal_target_id"] == signal["id"] + assert qt_metadata(event)["receiver_target_id"] == receiver["id"] + + +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml016_ac04_ancestor_edges_reach_reload_query_and_affected(tmp_path, directed): + """Exact source bytes and typed mechanisms survive actual durable consumers.""" + source = ("\ufeff// café 雪\n" + SOURCE).replace("\n", "\r\n") + result = analysis(tmp_path, {"events.cpp": source}) + index = QtEventIndex(result["nodes"]) + resolved = index.member({"form": "member_pointer", "class_name": "Child", "member_name": "changed"}, + "Child", role="signal") + assert resolved.status == "resolved" + signal = next(node for node in sites(result, "member") if node["id"] == resolved.target_id) + raw = (tmp_path / "events.cpp").read_bytes() + span = qt_metadata(signal)["span"] + assert raw[span["start_byte"]:span["end_byte"]] == b"void changed(int value);" + graph = build_from_json(result, root=tmp_path, directed=directed) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path)) + reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + for node in sites(result, "class"): + assert qt_metadata(reloaded.nodes[node["id"]]) == qt_metadata(node) + accepted = QtEventIndex([{"id": identity, **data} for identity, data in reloaded.nodes(data=True)]) + assert accepted.member({"form": "member_pointer", "class_name": "Child", "member_name": "changed"}, + "Child", role="signal").target_id == resolved.target_id + native_sites = sites(result, "emission") + sites(result, "connect") + sites(result, "disconnect") + for node in native_sites: + assert reloaded.nodes[node["id"]]["metadata"] == graph.nodes[node["id"]]["metadata"] + assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(node["id"], data=True)) + assert reloaded.has_edge(native_sites[0]["id"], signal["id"]) + hits = {hit.node_id for hit in affected_nodes(reloaded, signal["id"], depth=3)} + assert all(node["id"] in hits for node in native_sites) + assert qt_metadata(native_sites[0])["owner_id"] in hits + assert "Qt emission: changed" in _query_graph_text(reloaded, "Child changed", token_budget=4000) + + +@pytest.mark.parametrize("control", ["own_ordinary", "middle_ordinary", "own_wrong_signature", "private"]) +def test_req_qml016_ac01_shadowing_precedes_role_signature_and_visibility(tmp_path, control): + """A first same-name declaration cannot fall through to a convenient signal.""" + source = SOURCE + if control == "own_ordinary": + source = source.replace("public: void run()", "public: void changed(int value) {} void run()") + elif control == "middle_ordinary": + source = source.replace("public Grand { Q_OBJECT", "public Grand { Q_OBJECT public: void changed(int value) {}") + elif control == "own_wrong_signature": + source = source.replace("public: void run()", "signals: void changed(double value); public: void run()") + else: + source = source.replace("signals: void changed", "private: Q_SIGNAL void changed") + result = analysis(tmp_path, {"events.cpp": source}) + events = sites(result, "connect") + if control in {"own_ordinary", "middle_ordinary"}: + assert all(qt_metadata(node)["signal_status"] != "resolved" for node in events) + elif control == "own_wrong_signature": + assert qt_metadata(events[0])["signal_status"] == "resolved" + target = next(node for node in result["nodes"] if node["id"] == qt_metadata(events[0])["signal_target_id"]) + assert qt_metadata(target)["class_name"] == "Child" + assert qt_metadata(events[1])["signal_status"] == "unavailable" + else: + assert qt_metadata(events[0])["signal_status"] == "unavailable" + assert qt_metadata(events[1])["status"] == "resolved" + + +@pytest.mark.parametrize("conflict", [False, True]) +def test_req_qml016_ac01_diamond_deduplicates_declarations_and_rejects_conflicts(tmp_path, conflict): + """Repeated canonical paths are one fact; distinct branch declarations compete.""" + extra = "signals: void changed(int value);" if conflict else "" + source = SOURCE.replace("class Middle : public Grand { Q_OBJECT };", f'''class Left : public Grand {{ Q_OBJECT }}; +class Right : public Grand {{ Q_OBJECT {extra} }}; +class Middle : public Left, public Right {{ Q_OBJECT }};''') + result = analysis(tmp_path, {"events.cpp": source}) + event = sites(result, "connect")[0] + assert qt_metadata(event)["signal_status"] == ("ambiguous" if conflict else "resolved") + if conflict: + assert len(qt_metadata(event)["signal_candidates"]) == 2 + else: + target = qt_metadata(sites(result, "emission")[0])["target_id"] + assert qt_metadata(next(node for node in result["nodes"] if node["id"] == target))["class_name"] == "Grand" + + +@pytest.mark.parametrize("control", ["forward", "duplicate", "unknown_branch", "cycle", "conditional_alias"]) +def test_req_qml016_ac01_unproven_ancestry_cannot_choose_an_endpoint(tmp_path, control): + """Incomplete, ambiguous, cyclic or hidden base authority rejects static joins.""" + source = SOURCE + if control == "forward": + source = source.replace("class Middle : public Grand { Q_OBJECT };", "class Middle;") + elif control == "duplicate": + source += "\nclass Middle : public Grand { Q_OBJECT };\n" + elif control == "unknown_branch": + source = source.replace("public Middle {", "public Middle, public Missing {") + elif control == "cycle": + source = source.replace("public Grand {", "public Child {") + else: + source = source.replace("class Middle : public Grand", "#if SELECT\nusing Base = Grand;\n#endif\nclass Middle : public Base") + result = analysis(tmp_path, {"events.cpp": source}) + assert all(qt_metadata(node).get("signal_status") != "resolved" for node in sites(result, "connect")) + assert qt_metadata(sites(result, "emission")[0])["status"] != "resolved" + + +@pytest.mark.parametrize("depth,expected", [(31, "resolved"), (32, "unavailable")]) +def test_req_qml016_ac01_ancestor_traversal_has_a_fail_closed_32_class_budget(tmp_path, depth, expected): + """The last admitted class resolves; an additional class is never truncated to proof.""" + chain = "class Grand : public QObject { Q_OBJECT signals: void changed(int value); };\n" + previous = "Grand" + for number in range(depth): + current = f"Level{number}" + chain += f"class {current} : public {previous} {{ Q_OBJECT }};\n" + previous = current + chain += f"void wire({previous} *object) {{ QObject::connect(object, &{previous}::changed, object, [](int value){{}}); }}" + result = analysis(tmp_path, {"events.cpp": chain}) + assert qt_metadata(sites(result, "connect")[0])["signal_status"] == expected + if expected == "unavailable": + assert qt_metadata(sites(result, "connect")[0])["signal_reason"] == "inheritance_cycle_or_limit" + + +def test_req_qml016_ac01_lexical_namespace_and_alias_bases_keep_exact_authority(tmp_path): + """An unrelated global class cannot replace a namespaced aliased ancestor.""" + source = "namespace Public {\n" + SOURCE.replace("class Middle : public Grand", "using Alias = Grand;\nclass Middle : public Alias") + "}\n" + source += "class Grand: public QObject {Q_OBJECT signals: void changed(double value);};" + result = analysis(tmp_path, {"events.cpp": source}) + assert all(qt_metadata(event)["status"] == "resolved" for event in sites(result, "connect")) + target = qt_metadata(sites(result, "emission")[0])["target_id"] + assert qt_metadata(next(node for node in result["nodes"] if node["id"] == target))["class_name"] == "Public::Grand" + + +@pytest.mark.parametrize("selected", [False, True]) +def test_req_qml016_ac01_ancestor_overloads_require_the_literal_selector(tmp_path, selected): + """Recursive lookup preserves signature selection rather than choosing one overload.""" + source = SOURCE.replace("void changed(int value);", "void changed(int value); void changed(double value);") + if selected: + source = source.replace("&Child::changed", "qOverload(&Child::changed)") + result = analysis(tmp_path, {"events.cpp": source}) + event = sites(result, "connect")[0] + assert qt_metadata(event)["signal_status"] == ("resolved" if selected else "ambiguous") + assert qt_metadata(sites(result, "connect")[1])["status"] == "resolved" + if selected: + target = qt_metadata(event)["signal_target_id"] + assert qt_metadata(next(node for node in result["nodes"] if node["id"] == target))["parameter_types"] == ["int"] + + +def test_req_qml016_ac01_competing_branch_role_cannot_disambiguate_cpp_name(tmp_path): + """An ordinary branch member still competes with a differently owned signal.""" + source = SOURCE.replace("class Middle : public Grand { Q_OBJECT };", '''class Other : public QObject { +Q_OBJECT public: void changed(int value) {} +}; +class Middle : public Grand, public Other { Q_OBJECT };''') + result = analysis(tmp_path, {"events.cpp": source}) + assert all(qt_metadata(event)["signal_status"] == "ambiguous" for event in sites(result, "connect")) + + +@pytest.mark.parametrize("control", ["base_status", "base_count", "base_shape", "class_status", "member_status"]) +def test_req_qml016_ac01_reloaded_incomplete_proof_is_rejected_without_mutation(tmp_path, control): + """Corrupted accepted transport or status cannot synthesize an ancestral edge.""" + result = analysis(tmp_path, {"events.cpp": SOURCE}) + nodes = copy.deepcopy(result["nodes"]) + if control == "member_status": + node = next(node for node in nodes if qt_metadata(node).get("raw_name") == "changed") + update_qt(node, status="unavailable") + else: + node = next(node for node in nodes if qt_metadata(node).get("kind") == "class" + and qt_metadata(node)["class_name"] == "Middle") + update_qt(node, **{"base_status": {"canonical_base_status": "unavailable"}, + "base_count": {"canonical_base_names": []}, + "base_shape": {"bases": None}, + "class_status": {"status": "ambiguous"}}[control]) + before = copy.deepcopy(nodes) + answer = QtEventIndex(nodes).member({"form": "member_pointer", "class_name": "Child", "member_name": "changed"}, + "Child", role="signal") + assert answer.status == "unavailable" and nodes == before + + +def test_req_qml016_ac01_explicit_ancestor_qualification_requires_receiver_compatibility(tmp_path): + """Grandparent qualification accepts Child identity and rejects an unrelated object.""" + result = analysis(tmp_path, {"events.cpp": SOURCE + "class Other : public QObject { Q_OBJECT };"}) + index = QtEventIndex(result["nodes"]) + endpoint = {"form": "member_pointer", "class_name": "Grand", "member_name": "changed"} + assert index.member(endpoint, "Child", role="signal").status == "resolved" + answer = index.member(endpoint, "Other", role="signal") + assert answer.status == "unavailable" and answer.reason == "object_member_type_mismatch" diff --git a/tests/test_qt_inherited_updates.py b/tests/test_qt_inherited_updates.py new file mode 100644 index 0000000000..3e86661b54 --- /dev/null +++ b/tests/test_qt_inherited_updates.py @@ -0,0 +1,165 @@ +"""REQ-QML-016-AC04 inherited endpoint refresh, retention and recovery.""" +from __future__ import annotations + +import os +import stat + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.test_qt_event_incremental_consumers import run +from tests.test_qt_final_incremental_parity import clean, normalized, unrelated +from tests.test_qt_product_publication import reject, snapshot + + +HEADER = '''class Grand : public QObject { Q_OBJECT +signals: void changed(int value); +public slots: void accept(int value) {} +}; +class Middle : public Grand { Q_OBJECT }; +class Child : public Middle { Q_OBJECT public: void run(); }; +''' +BODY = '''#include "events.h" +void Child::run() { emit changed(1); } +void wire(Child *sender, Child *receiver) { + QObject::connect(sender, &Child::changed, receiver, &Child::accept); + QObject::connect(sender, SIGNAL(changed(int)), receiver, SLOT(accept(int))); +} +''' + + +def fixture(root): + """Header declarations own targets; unchanged implementation owns event sites.""" + (root / "events.h").write_bytes(HEADER.encode()) + (root / "events.cpp").write_bytes(BODY.encode()) + (root / "keep.py").write_text("def helper(): return 7\n\ndef retained(): return helper()\n" + + "\n".join(f"def keep_{index}(): return {index}" for index in range(20)), encoding="utf-8") + return root / "events.h", root / "events.cpp" + + +def endpoints(graph, expected: str | None = "changed", event_count=3): + """Connections and emissions preserve explicit roles and source declaring class.""" + events = {identity: qt_metadata(data) for identity, data in graph.nodes(data=True) + if qt_metadata(data).get("kind") in {"connect", "emission"}} + assert len(events) == event_count + for identity, metadata in events.items(): + if expected is None: + assert metadata["status"] != "resolved" + assert not metadata.get("signal_target_id") and not metadata.get("target_id") + else: + assert metadata["status"] == "resolved" + target = metadata.get("signal_target_id") or metadata.get("target_id") + declaration = qt_metadata(graph.nodes[target]) + assert declaration["class_name"] == "Grand" and declaration["raw_name"] == expected + assert graph.nodes[target]["source_file"] == "events.h" + assert not any(data.get("relation") == "calls" for _, _, data in graph.edges(identity, data=True)) + return events + + +def parity(graph, root, key): + """A fresh and a reused AST cache must produce the entire same public graph.""" + cache = root / (".clean-" + key) + assert normalized(graph) == normalized(clean(root, cache)) == normalized(clean(root, cache)) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml016_ac04_header_signal_base_and_site_edits_match_full_cold_warm(tmp_path, monkeypatch, operation): + """Header-only edits rejoin existing sites; removal eliminates stale endpoint facts.""" + header, body = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + endpoints(initial) + parity(initial, tmp_path, "initial") + untouched = unrelated(initial) + old_signal = next(md.get("signal_target_id") or md["target_id"] for md in endpoints(initial).values()) + + header.write_bytes(HEADER.replace("changed", "revised").encode()) + changed = run(tmp_path, monkeypatch, operation, [header]) + # INC-QML-28 retains explicit source syntax after declaration removal; + # the emission and connection observations cannot retain old targets. + endpoints(changed, expected=None) + parity(changed, tmp_path, "header") + assert old_signal not in changed + body.write_bytes(BODY.replace("changed", "revised").encode()) + repaired = run(tmp_path, monkeypatch, operation, [body]) + endpoints(repaired, expected="revised") + parity(repaired, tmp_path, "repair") + + header.write_bytes(HEADER.replace("changed", "revised").replace("public Grand", "public Missing").encode()) + disconnected = run(tmp_path, monkeypatch, operation, [header]) + endpoints(disconnected, expected=None) + parity(disconnected, tmp_path, "base") + header.write_bytes(HEADER.replace("changed", "revised").encode()) + restored = run(tmp_path, monkeypatch, operation, [header]) + assert normalized(restored) == normalized(repaired) + + # Inheritance access is a source fact and header-only invalidation input. + # Nonpublic bases reject external C++ pointers while legacy meta lookup + # retains its independently admitted declaration identity. + header.write_bytes(HEADER.replace("changed", "revised").replace("public Grand", "private Grand").encode()) + restricted = run(tmp_path, monkeypatch, operation, [header]) + connections = [qt_metadata(data) for _, data in restricted.nodes(data=True) + if qt_metadata(data).get("kind") == "connect"] + modern = next(md for md in connections if md["signal"]["form"] == "member_pointer") + legacy = next(md for md in connections if md["signal"]["form"] == "legacy") + assert modern["status"] == "unavailable" and modern["signal_reason"] == "inheritance_access_unavailable" + assert legacy["status"] == "resolved" + parity(restricted, tmp_path, "access") + header.write_bytes(HEADER.replace("changed", "revised").encode()) + assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(repaired) + + removed_text = BODY.replace("emit changed(1);", "") + removed_text = "\n".join(line for line in removed_text.splitlines() if "QObject::connect" not in line) + body.write_bytes(removed_text.encode()) + removed = run(tmp_path, monkeypatch, operation, [body]) + parity(removed, tmp_path, "removed") + assert all(identity not in removed for identity in endpoints(restored, expected="revised")) + assert not any(qt_metadata(data).get("kind") == "event_endpoint" for _, data in removed.nodes(data=True)) + assert unrelated(removed) == untouched + current = snapshot(tmp_path / "graphify-out") + assert normalized(run(tmp_path, monkeypatch, operation, [header, body])) == normalized(removed) + assert snapshot(tmp_path / "graphify-out") == current + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml016_ac04_malformed_ancestor_preserves_products_then_retries(tmp_path, monkeypatch, operation): + """Rejected native source cannot advance durable endpoint evidence or sidecars.""" + header, _ = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + endpoints(initial) + output = tmp_path / "graphify-out" + before = snapshot(output) + header.write_bytes(b"class Grand : public QObject { Q_OBJECT signals: void changed( ;\n") + reject(tmp_path, monkeypatch, operation, header) + assert snapshot(output) == before + header.write_bytes(HEADER.encode()) + recovered = run(tmp_path, monkeypatch, operation, [header]) + assert normalized(recovered) == normalized(initial) + parity(recovered, tmp_path, "syntax-retry") + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary") +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml016_ac04_readonly_ancestor_refresh_preserves_cohort_and_retry(tmp_path, monkeypatch, operation): + """A real late sidecar denial retains graph/root/manifest/Qt stamp before recovery.""" + header, _ = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out" + before = snapshot(output) + header.write_bytes(HEADER.replace("changed", "revised").encode()) + target = output / "manifest.json" + target.chmod(stat.S_IREAD) + try: + reject(tmp_path, monkeypatch, operation, header) + assert snapshot(output) == before + finally: + target.chmod(stat.S_IWRITE) + recovered = run(tmp_path, monkeypatch, operation, [header]) + endpoints(recovered, expected=None) + assert normalized(recovered) != normalized(initial) + parity(recovered, tmp_path, "publication-retry") + accepted = snapshot(output) + assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(recovered) + assert snapshot(output) == accepted diff --git a/tests/test_qt_initial_repeat.py b/tests/test_qt_initial_repeat.py new file mode 100644 index 0000000000..8aae3b6be4 --- /dev/null +++ b/tests/test_qt_initial_repeat.py @@ -0,0 +1,156 @@ +"""INC-QML-32: first raw publication remains byte- and mtime-stable on a repeat.""" +from __future__ import annotations + +import copy +import json +import os + +import pytest + +import graphify.__main__ as entrypoint +import graphify.watch as watch +from graphify.paths import load_node_link_graph +from tests.qt_combined_adoption_fixture import corpus +from tests.test_qt_combined_adoption import accepted + + +RUN_FIELDS = ("diagnostics", "failed_sources", "qml_failures", "qt_failures", "hyperedges") + + +def update(root, monkeypatch, operation, changes=None): + """Exercise the ordinary raw CLI/watch publication without prewarming extraction.""" + if operation == "manual": + monkeypatch.setattr(entrypoint, "_check_skill_version", lambda *_: None) + monkeypatch.setattr(entrypoint, "_refresh_stale_skills", lambda: None) + monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "update", str(root), "--no-cluster"]) + entrypoint.main() + return True + return watch._rebuild_code(root, changed_paths=changes, no_cluster=True) + + +def pin_graph_mtime(path): + # An explicit old timestamp detects even a same-content replacement on + # filesystems whose normal timestamp precision hides fast repeat writes. + before = path.stat() + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns - 10_000_000_000)) + return path.read_bytes(), path.stat().st_mtime_ns + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("build_system", ["cmake", "qmake"]) +@pytest.mark.parametrize("scope", ["whole", "subfolder"]) +def test_cold_combined_no_cluster_repeat_keeps_bytes_mtime_and_changed_source(tmp_path, monkeypatch, + operation, build_system, scope): + """The first real Qt publication must survive an unchanged run, while a genuine edit is published.""" + app = corpus(tmp_path, build_system) + root = tmp_path if scope == "whole" else app + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["app/imports"]' if scope == "whole" else '["imports"]') + assert update(root, monkeypatch, operation) + path = root / "graphify-out/graph.json" + accepted(load_node_link_graph(path)) + original, timestamp = pin_graph_mtime(path) + assert update(root, monkeypatch, operation) + assert path.read_bytes() == original and path.stat().st_mtime_ns == timestamp + # Repeated success must never mask a real native/QML source fact change. + source = app / "Main.qml" + source.write_bytes(source.read_bytes().replace(b"handleReady", b"received")) + assert update(root, monkeypatch, operation, [source]) + assert path.read_bytes() != original and path.stat().st_mtime_ns != timestamp + accepted(load_node_link_graph(path)) + edited, timestamp = pin_graph_mtime(path) + assert update(root, monkeypatch, operation) + assert path.read_bytes() == edited and path.stat().st_mtime_ns == timestamp + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_cold_generic_no_cluster_repeat_does_not_rewrite_first_graph(tmp_path, monkeypatch, operation): + """Unrelated Python keeps the same first-publication promise through both production drivers.""" + (tmp_path / "keep.py").write_bytes(b"def helper(): return 7\n\ndef retained(): return helper()\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + assert update(tmp_path, monkeypatch, operation) + path = tmp_path / "graphify-out/graph.json" + original, timestamp = pin_graph_mtime(path) + assert update(tmp_path, monkeypatch, operation) + assert path.read_bytes() == original and path.stat().st_mtime_ns == timestamp + + +@pytest.mark.parametrize("field", RUN_FIELDS) +def test_known_absent_and_empty_run_lists_compare_equal_without_mutation(field): + """Only explicitly owned empty-list omissions are representation-equivalent.""" + raw = {"nodes": [{"id": "kept", "metadata": {"source_proof": {"span": [1, 9]}}}], "links": []} + populated = {**raw, field: [], "extracted_sources": ["first.cpp"], "built_at_commit": "first"} + before = copy.deepcopy(populated) + assert watch._canonical_graph_for_compare(raw) == watch._canonical_graph_for_compare(populated) + assert populated == before + + +@pytest.mark.parametrize("field", RUN_FIELDS) +@pytest.mark.parametrize("value", [[{"code": "QT_DECLARATION_CONFLICT", "line": 1}], None, {}, "", 0]) +def test_nonempty_or_wrong_type_run_lists_remain_observable(field, value): + """Meaningful diagnostics, failures, hyperedges and malformed transport are never stripped.""" + raw = {"nodes": [], "links": []} + populated = {**raw, field: value} + assert watch._canonical_graph_for_compare(raw) != watch._canonical_graph_for_compare(populated) + + +@pytest.mark.parametrize("change", ["unknown", "node_proof", "edge_proof", "native_identity"]) +def test_unknown_metadata_and_source_facts_still_compare_different(change): + """The comparison cannot blanket-strip graph metadata or any native/source relationship proof.""" + raw: dict = {"nodes": [{"id": "kept", "metadata": {"source_proof": {"span": [1, 9]}}}], + "links": [{"source": "kept", "target": "other", "metadata": {"native_endpoint": "actual"}}]} + edited = copy.deepcopy(raw) + if change == "unknown": + edited["extension_policy"] = {"revision": 2} + elif change == "node_proof": + edited["nodes"][0]["metadata"]["source_proof"]["span"] = [2, 9] + elif change == "edge_proof": + edited["links"][0]["metadata"]["native_endpoint"] = "different" + else: + edited["links"][0]["target"] = "different" + assert watch._canonical_graph_for_compare(raw) != watch._canonical_graph_for_compare(edited) + + +def test_topology_comparison_keeps_existing_stage_metadata_contract(): + """The clustered topology shortcut is deliberately outside this normalization owner.""" + raw = {"nodes": [], "links": []} + populated = {**raw, "diagnostics": [], "hyperedges": [], "extracted_sources": ["first.cpp"]} + assert watch._canonical_topology_for_compare(raw) != watch._canonical_topology_for_compare(populated) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_real_raw_publication_failure_retains_prior_then_recovers(tmp_path, monkeypatch, operation): + """Normalization retains the existing failed replacement and recovery behavior for genuine edits.""" + source = tmp_path / "keep.py" + source.write_bytes(b"def original(): return 7\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + assert update(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out" + path = output / "graph.json" + original, timestamp = pin_graph_mtime(path) + prior = {name: (output / name).read_bytes() for name in ("manifest.json", ".qt_analysis.json", ".graphify_root")} + source.write_bytes(b"def renamed(): return 9\n") + replace = watch.os_replace_with_fallback + + def reject_graph(staged, destination, **kwargs): + if os.fspath(destination).endswith("graph.json"): + raise OSError("simulated graph replacement failure") + return replace(staged, destination, **kwargs) + + with monkeypatch.context() as fault: + fault.setattr(watch, "os_replace_with_fallback", reject_graph) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + update(tmp_path, monkeypatch, operation, [source]) + assert rejected.value.code == 1 + else: + assert not update(tmp_path, monkeypatch, operation, [source]) + assert path.read_bytes() == original and path.stat().st_mtime_ns == timestamp + assert all((output / name).read_bytes() == content for name, content in prior.items()) + assert not list(output.glob(".gfy-publish-*")) + assert update(tmp_path, monkeypatch, operation, [source]) + assert path.read_bytes() != original + assert any(node.get("label") == "renamed()" for node in json.loads(path.read_bytes())["nodes"]) diff --git a/tests/test_qt_json_direction.py b/tests/test_qt_json_direction.py new file mode 100644 index 0000000000..a26cfe4558 --- /dev/null +++ b/tests/test_qt_json_direction.py @@ -0,0 +1,185 @@ +"""INC-QML-31: JSON export rejects forged direction before durable publication.""" +from __future__ import annotations + +import copy +import json +import sys +from types import SimpleNamespace + +import networkx as nx +import pytest + +from graphify.affected import affected_nodes +from graphify.build import build_from_json +from graphify.export import to_cypher, to_json +from graphify.exporters.graphdb import push_to_falkordb, push_to_neo4j +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qt_export import preflight_qt_payload, qt_cypher_lines +from tests.qt_analysis_helpers import analysis +from tests.test_qt_qml_integration import corpus + + +def actual_graph(root, directed=False): + """Accepted original QML source supplies real typed reference and source-owner facts.""" + result = analysis(root, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"}) + graph = build_from_json(result, root=root, directed=directed) + source = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "read") + target = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "property" + and qml_metadata(node).get("raw_name") == "input") + return graph, source, target + + +def damage(data, corruption, source, target): + """Mutate only marker transport, preserving the actual accepted graph endpoint pair.""" + data["_src"], data["_tgt"] = source, target + if corruption == "partial_source": + data.pop("_tgt") + elif corruption == "partial_target": + data.pop("_src") + elif corruption == "foreign_source": + data["_src"] = "private-needle\nforged diagnostic" + elif corruption == "foreign_target": + data["_tgt"] = "foreign" + elif corruption == "null": + data["_src"] = None + elif corruption == "list": + data["_src"] = [source] + elif corruption == "mapping": + data["_tgt"] = {"id": target} + elif corruption == "collapsed": + data["_tgt"] = source + else: + data["_src"], data["_tgt"] = target, source + + +@pytest.mark.parametrize("directed", [False, True]) +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("corruption", ["partial_source", "partial_target", "foreign_source", "foreign_target", + "null", "list", "mapping", "collapsed"]) +def test_json_direction_rejection_preserves_previous_bytes_and_safe_diagnostic(tmp_path, capsys, + directed, force, corruption): + """Neither explicit force nor malformed marker types can replace the previous accepted graph.""" + graph, source, target = actual_graph(tmp_path, directed) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path), built_at_commit="fixed") + accepted = path.read_bytes() + edge = graph.edges[source, target] + damage(edge, corruption, source, target) + before = copy.deepcopy(nx.node_link_data(graph, edges="links")) + assert to_json(graph, {}, str(path), force=force, built_at_commit="fixed") is False + assert path.read_bytes() == accepted + assert nx.node_link_data(graph, edges="links") == before + diagnostic = capsys.readouterr().err + assert "QT_EXPORT_DIRECTION" in diagnostic + assert "private-needle" not in diagnostic and "forged diagnostic" not in diagnostic + assert str(path) not in diagnostic + # Repair uses the same production writer; repeated success preserves bytes + # and the actual read remains a dependency after the durable reload. + edge["_src"], edge["_tgt"] = source, target + assert to_json(graph, {}, str(path), built_at_commit="fixed") + assert path.read_bytes() == accepted + loaded = load_node_link_graph(path) + assert source in {hit.node_id for hit in affected_nodes(loaded, target, depth=1)} + + +def test_json_directed_contradiction_rejects_before_first_file_exists(tmp_path, capsys): + """A directed graph cannot reverse its real accepted pair; refusal creates no partial JSON.""" + graph, source, target = actual_graph(tmp_path, directed=True) + damage(graph.edges[source, target], "reversed", source, target) + path = tmp_path / "first.json" + assert to_json(graph, {}, str(path), force=True) is False + assert not path.exists() and "QT_EXPORT_DIRECTION" in capsys.readouterr().err + + +@pytest.mark.parametrize("graph_type", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph]) +def test_legacy_unmarked_edges_and_valid_self_pairs_preserve_native_identity(tmp_path, graph_type): + """Generic graphs retain native direction, loops, parallel facts and original values.""" + graph = graph_type() + graph.add_edge("caller", "callee", relation="calls", context="ordinary", confidence="EXTRACTED") + graph.add_edge("caller", "caller", relation="references", _src="caller", _tgt="caller") + if graph.is_multigraph(): + graph.add_edge("caller", "callee", relation="references", context="second") + before = copy.deepcopy(nx.node_link_data(graph, edges="links")) + path = tmp_path / "legacy.json" + assert to_json(graph, {}, str(path), built_at_commit="fixed") + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["directed"] == graph.is_directed() and payload["multigraph"] == graph.is_multigraph() + assert len(payload["links"]) == graph.number_of_edges() + assert all((edge["source"], edge["target"]) in {(u, v) for u, v in graph.edges()} for edge in payload["links"]) + assert nx.node_link_data(graph, edges="links") == before + assert to_json(graph, {}, str(path), built_at_commit="fixed") + + +def test_undirected_reversed_storage_retains_real_qml_dependency_direction(tmp_path): + """Reversing physical insertion order must not reverse an accepted QML read after reload.""" + graph, source, target = actual_graph(tmp_path) + reversed_graph = nx.Graph() + reversed_graph.add_node(target, **graph.nodes[target]) + reversed_graph.add_nodes_from((identity, data) for identity, data in graph.nodes(data=True) if identity != target) + reversed_graph.add_edges_from((v, u, copy.deepcopy(data)) for u, v, data in graph.edges(data=True)) + assert next((u, v) for u, v in reversed_graph.edges() if {u, v} == {source, target}) == (target, source) + path = tmp_path / "reversed.json" + assert to_json(reversed_graph, {}, str(path)) + payload = json.loads(path.read_text(encoding="utf-8")) + edge = next(data for data in payload["links"] if data.get("context") == "qml_binding_read") + assert (edge["source"], edge["target"]) == (source, target) + assert source in {hit.node_id for hit in affected_nodes(load_node_link_graph(path), target, depth=1)} + + +@pytest.mark.parametrize("directed", [False, True]) +def test_native_source_proof_export_reload_consumer_survives_rejection_and_repair(tmp_path, directed): + """Actual C++ registration joins preserve exact native endpoint proof across the corrected boundary.""" + result = corpus(tmp_path) + call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call") + source, target = call["source"], call["target"] + assert qt_metadata(call)["native_endpoint"]["canonical_target_id"] == target + graph = build_from_json(result, root=tmp_path, directed=directed) + path = tmp_path / "native.json" + assert to_json(graph, {}, str(path), built_at_commit="fixed") + accepted = path.read_bytes() + edge = graph.edges[source, target] + damage(edge, "foreign_source", source, target) + assert to_json(graph, {}, str(path), force=True) is False and path.read_bytes() == accepted + edge["_src"], edge["_tgt"] = source, target + assert to_json(graph, {}, str(path), built_at_commit="fixed") and path.read_bytes() == accepted + restored = load_node_link_graph(path) + assert qt_metadata(restored.edges[source, target])["native_endpoint"] == qt_metadata(call)["native_endpoint"] + assert restored.edges[source, target]["source_location"] == call["source_location"] + assert source in {hit.node_id for hit in affected_nodes(restored, target, depth=1)} + + +@pytest.mark.parametrize("corruption", ["partial_source", "partial_target", "foreign_source", "foreign_target", + "null", "list", "mapping", "collapsed"]) +def test_external_direction_rejection_precedes_file_or_database_connection(tmp_path, monkeypatch, corruption): + """External preflight shares strict malformed-pair rejection without printing untrusted values.""" + graph, source, target = actual_graph(tmp_path) + damage(graph.edges[source, target], corruption, source, target) + before = copy.deepcopy(nx.node_link_data(graph, edges="links")) + path = tmp_path / "previous.cypher" + path.write_bytes(b"previous valid export") + + def forbidden(*_, **__): + pytest.fail("Direction rejection must precede database creation or connection") + + monkeypatch.setitem(sys.modules, "neo4j", SimpleNamespace(GraphDatabase=SimpleNamespace(driver=forbidden))) + monkeypatch.setitem(sys.modules, "falkordb", SimpleNamespace(FalkorDB=forbidden)) + for action in (lambda: preflight_qt_payload(graph), lambda: qt_cypher_lines(graph), + lambda: to_cypher(graph, str(path)), lambda: push_to_neo4j(graph, "unused", "", ""), + lambda: push_to_falkordb(graph, "unused")): + with pytest.raises(ValueError, match="QT_EXPORT_DIRECTION") as rejected: + action() + assert "private-needle" not in str(rejected.value) and "forged diagnostic" not in str(rejected.value) + assert path.read_bytes() == b"previous valid export" and nx.node_link_data(graph, edges="links") == before + + +def test_external_bidirectional_wrapper_honors_logical_pair_but_json_requires_native_direction(tmp_path): + """The external adapter retains its wrapper contract while JSON refuses contradictory native identity.""" + graph, source, target = actual_graph(tmp_path) + wrapper = nx.MultiDiGraph(graph) + preflight_qt_payload(wrapper) + lines = qt_cypher_lines(wrapper) + assert lines and any(f'id:{json.dumps(source)}' in line and f'id:{json.dumps(target)}' in line for line in lines) + path = tmp_path / "wrapper.json" + assert to_json(wrapper, {}, str(path), force=True) is False and not path.exists() diff --git a/tests/test_qt_json_direction_updates.py b/tests/test_qt_json_direction_updates.py new file mode 100644 index 0000000000..71facb69f6 --- /dev/null +++ b/tests/test_qt_json_direction_updates.py @@ -0,0 +1,117 @@ +"""INC-QML-31: rejected JSON direction preserves the real manual/watch cohort.""" +from __future__ import annotations + +from collections import Counter +import json + +import pytest + +import graphify.__main__ as entrypoint +import graphify.export as export +import graphify.watch as watch +from graphify.affected import affected_nodes +from graphify.cache import cache_dir +from graphify.extractors.qml_facts import qml_metadata +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis +from tests.test_qt_final_incremental_parity import clean, normalized + + +PRODUCTS = ("graph.json", ".graphify_root", "manifest.json", ".qt_analysis.json", "GRAPH_REPORT.md", + ".graphify_labels.json", ".graphify_labels.json.sig") + + +def snapshot(output): + return {name: (output / name).read_bytes() for name in PRODUCTS} + + +def source_contract(graph, *, only_python=False): + """Compare exact source facts while excluding the clustered display label.""" + nodes, edges = normalized(graph) + + def source_data(serialized): + data = json.loads(serialized) + # Cold analysis has no communities. This presentation label is supplied + # by clustering; direction, source proof and every other fact remain. + data.pop("community_name", None) + return json.dumps(data, sort_keys=True, ensure_ascii=False) + + identities = {identity for identity, data in graph.nodes(data=True) + if not only_python or data.get("source_file") == "keep.py"} + return ({identity: source_data(data) for identity, data in nodes.items() if identity in identities}, + Counter({(source, target, source_data(data)): count for (source, target, data), count in edges.items() + if source in identities and target in identities})) + + +def clustered(root, monkeypatch, operation, changes=None): + """Both real entry points use clustered JSON export, not the raw no-cluster writer.""" + if operation == "manual": + monkeypatch.setattr(entrypoint, "_check_skill_version", lambda *_: None) + monkeypatch.setattr(entrypoint, "_refresh_stale_skills", lambda: None) + monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "update", str(root)]) + entrypoint.main() + else: + assert watch._rebuild_code(root, changed_paths=changes) + return load_node_link_graph(root / "graphify-out/graph.json") + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("corruption", ["foreign", "partial"]) +def test_rejected_direction_clustered_publication_retains_products_cache_and_repairs(tmp_path, monkeypatch, + capsys, operation, corruption): + """Only marker transport is faulted at the real graph serializer; disk publication must reject then recover.""" + analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }", + "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n"}) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = clustered(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out" + prior = snapshot(output) + kept = source_contract(initial, only_python=True) + ast = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")} + assert ast + source = tmp_path / "Main.qml" + source.write_bytes(source.read_bytes().replace(b"output: input", b"output: input + 2")) + original_export = export.to_json + seen = [] + + def corrupt_direction(graph, communities, output_path, **kwargs): + # This seam receives the actual extraction/build result. The real JSON + # writer below owns validation, the rejection result and durable writes. + read = next(identity for identity, data in graph.nodes(data=True) if qml_metadata(data).get("kind") == "read") + target = next(identity for identity, data in graph.nodes(data=True) if qml_metadata(data).get("kind") == "property" + and qml_metadata(data).get("raw_name") == "input") + edge = graph.edges[read, target] + if corruption == "foreign": + edge["_src"] = "private-needle\nforged diagnostic" + else: + edge.pop("_src") + result = original_export(graph, communities, output_path, **kwargs) + seen.append(result) + return result + + with monkeypatch.context() as fault: + fault.setattr(export, "to_json", corrupt_direction) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + clustered(tmp_path, monkeypatch, operation, [source]) + assert rejected.value.code == 1 + else: + assert not watch._rebuild_code(tmp_path, changed_paths=[source]) + assert seen == [False] and snapshot(output) == prior + assert all(path.read_bytes() == contents for path, contents in ast.items()) + diagnostic = capsys.readouterr().err + assert "QT_EXPORT_DIRECTION" in diagnostic + assert "private-needle" not in diagnostic and "forged diagnostic" not in diagnostic + assert not (output / ".graph.tmp.json").exists() and not list(output.glob(".gfy-publish-*")) + + repaired = clustered(tmp_path, monkeypatch, operation, [source]) + assert source_contract(repaired) == source_contract(clean(tmp_path, tmp_path / ".clean-cache")) + assert source_contract(repaired, only_python=True) == kept and source_contract(repaired) != source_contract(initial) + read = next(identity for identity, data in repaired.nodes(data=True) if qml_metadata(data).get("kind") == "read") + target = next(identity for identity, data in repaired.nodes(data=True) if qml_metadata(data).get("kind") == "property" + and qml_metadata(data).get("raw_name") == "input") + assert read in {hit.node_id for hit in affected_nodes(repaired, target, depth=1)} + current = snapshot(output) + assert normalized(clustered(tmp_path, monkeypatch, operation, [])) == normalized(repaired) + assert snapshot(output) == current diff --git a/tests/test_qt_loader_boundaries.py b/tests/test_qt_loader_boundaries.py new file mode 100644 index 0000000000..db89452d93 --- /dev/null +++ b/tests/test_qt_loader_boundaries.py @@ -0,0 +1,174 @@ +"""Literal loader overloads must preserve source authority without Qt execution.""" +from __future__ import annotations + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_analysis_helpers import analysis, sites + + +def corpus(tmp_path, body, declarations=""): + """A public resource URL avoids assuming the analyzed app's working directory.""" + return analysis(tmp_path, { + "access.cpp": declarations + "\nvoid use() { " + body + " }", + "Main.qml": "import QtQml\nQtObject { property int value: 1 }", + "ui.qrc": 'Main.qml', + }) + + +@pytest.mark.parametrize("setup,root", [ + ('QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), + ('QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"), nullptr);', 'engine.rootObjects().first()'), + ('QQmlApplicationEngine engine(nullptr); engine.load(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), + ('QQmlEngine engine; QQmlComponent component(&engine, nullptr); component.loadUrl(QUrl("qrc:/ui/Main.qml"));', 'component.create()'), + ('QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"), QQmlComponent::Asynchronous);', 'component.create()'), + ('QQmlEngine engine; QQmlComponent component(&engine); component.loadUrl(QUrl("qrc:/ui/Main.qml"), QQmlComponent::PreferSynchronous);', 'component.create()'), + ('QQuickView view; view.setSource(QUrl("qrc:/ui/Main.qml"));', 'view.rootObject()'), + ('QQmlApplicationEngine engine(":/ui/Main.qml");', 'engine.rootObjects().first()'), +]) +def test_req_qml017_ac01_supported_loader_overloads_have_one_source(tmp_path, setup, root): + """Parent-only construction supplies identity, never a false second load.""" + result = corpus(tmp_path, setup + ' auto root = ' + root + '; root->property("value");') + loads = sites(result, "qml_load") + assert len(loads) == 1 and qt_metadata(loads[0])["status"] == "resolved" + member = sites(result, "qml_access", "property")[0] + assert qt_metadata(member)["status"] == "resolved" + assert any(edge["source"] == member["id"] and edge["target"] == qt_metadata(member)["target_id"] + for edge in result["edges"]) + + +@pytest.mark.parametrize("expression", [ + 'QUrl::fromLocalFile("qrc:/ui/Main.qml")', + 'QUrl::fromLocalFile("file:///ui/Main.qml")', + 'QUrl::fromLocalFile("Main.qml")', + 'QUrl(QUrl::fromLocalFile("qrc:/ui/Main.qml"))', + 'QUrl(":/ui/Main.qml")', + 'QUrl(prefix + suffix)', +]) +@pytest.mark.parametrize("route", ["engine_constructor", "component_url"]) +def test_req_qml017_ac01_local_file_wrapper_cannot_become_resource_url(tmp_path, expression, route): + """Wrapper semantics and dynamic/local-base uncertainty cannot lend qrc facts.""" + setup = (f'QQmlApplicationEngine engine({expression}); auto root = engine.rootObjects().first();' + if route == "engine_constructor" else + f'QQmlEngine engine; QQmlComponent component(&engine); component.loadUrl({expression}); auto root = component.create();') + result = corpus(tmp_path, setup + ' root->property("value");') + loads = sites(result, "qml_load") + assert len(loads) == 1 and qt_metadata(loads[0])["status"] != "resolved" + assert not qt_metadata(loads[0]).get("target_id") + assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id") + + +def test_req_qml017_ac01_absolute_fromlocalfile_retains_file_component(tmp_path): + """An absolute accepted path is converted to a file URI, with exact QML target.""" + path = str(tmp_path / "Main.qml").replace("\\", "/") + result = corpus(tmp_path, f'QQmlApplicationEngine engine(QUrl::fromLocalFile("{path}")); ' + 'auto root = engine.rootObjects().first(); root->property("value");') + assert qt_metadata(sites(result, "qml_load")[0])["literal_url"] == (tmp_path / "Main.qml").as_uri() + assert qt_metadata(sites(result, "qml_access", "property")[0])["status"] == "resolved" + + +@pytest.mark.parametrize("declaration,local", [ + ('namespace App { class QUrl { public: QUrl(const char*); };', ''), + ('namespace App { ::QUrl QUrl(const char*) { return ::QUrl("qrc:/ui/Other.qml"); }', ''), + ('namespace App {', 'auto QUrl = [](const char*) { return ::QUrl("qrc:/ui/Other.qml"); };'), +]) +def test_req_qml017_ac01_url_wrapper_shadow_cannot_lend_literal_argument(tmp_path, declaration, local): + """A custom type/callable can construct a different value than its argument.""" + result = analysis(tmp_path, { + "access.cpp": declaration + ' void use() { ' + local + ' QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml")); ' + 'auto root = engine.rootObjects().first(); root->property("value"); } }', + "Main.qml": "import QtQml\nQtObject { property int value: 1 }", + "ui.qrc": 'Main.qml', + }) + assert not any(qt_metadata(node).get("target_id") for node in sites(result, "qml_load")) + assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id") + + +def test_req_qml017_ac01_custom_component_engine_cannot_authorize_constructor(tmp_path): + """A known local declaration named QQmlEngine does not prove SDK ownership.""" + result = corpus(tmp_path, 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml")); ' + 'auto root = component.create(); root->property("value");', 'class QQmlEngine {};') + assert not any(qt_metadata(node).get("target_id") for node in sites(result, "qml_load")) + assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id") + + +def test_req_qml017_ac01_global_sdk_constructor_bypasses_namespace_shadow(tmp_path): + """An explicit global SDK type retains authority beneath an unrelated name.""" + result = analysis(tmp_path, { + "access.cpp": 'namespace App { class QQmlApplicationEngine {}; void use() { ' + '::QQmlApplicationEngine engine(::QUrl("qrc:/ui/Main.qml")); ' + 'auto root = engine.rootObjects().first(); root->property("value"); } }', + "Main.qml": "import QtQml\nQtObject { property int value: 1 }", + "ui.qrc": 'Main.qml', + }) + assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved" + assert qt_metadata(sites(result, "qml_access", "property")[0])["status"] == "resolved" + + +@pytest.mark.parametrize("declarations,kind", [ + ("namespace Other { class QQmlApplicationEngine {}; }", "Other::QQmlApplicationEngine"), + ("class QQmlApplicationEngine {};", "QQmlApplicationEngine"), + ("class Fake {}; using QQmlApplicationEngine = Fake;", "QQmlApplicationEngine"), + ("namespace Other { class QQmlComponent {}; }", "Other::QQmlComponent"), +]) +def test_req_qml017_ac01_sdk_name_shadow_cannot_authorize_loader(tmp_path, declarations, kind): + """A qualified custom type or a local SDK-name declaration is not a Qt loader.""" + component = "Component" in kind + setup = (f'QQmlEngine engine; {kind} component(&engine); component.loadUrl(QUrl("qrc:/ui/Main.qml")); auto root = component.create();' + if component else + f'{kind} engine(QUrl("qrc:/ui/Main.qml")); engine.load(QUrl("qrc:/ui/Main.qml")); auto root = engine.rootObjects().first();') + result = corpus(tmp_path, setup + ' root->property("value");', declarations) + assert not any(qt_metadata(node).get("target_id") for node in sites(result, "qml_load")) + assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id") + + +@pytest.mark.parametrize("load,create", [ + ('component.loadUrl(QUrl("qrc:/ui/Main.qml"), computedMode)', 'component.create()'), + ('component.loadUrl(QUrl("qrc:/ui/Main.qml"), QQmlComponent::Asynchronous, nullptr)', 'component.create()'), + ('component.loadUrl(QUrl("qrc:/ui/Main.qml"))', 'component.create(otherContext)'), + ('component.loadUrl(QUrl("qrc:/ui/Main.qml")); component.loadUrl(QUrl("qrc:/ui/Main.qml"))', 'component.create()'), + ('if (enabled) component.loadUrl(QUrl("qrc:/ui/Main.qml"))', 'component.create()'), + ('component.loadUrl(QUrl("qrc:/ui/Missing.qml"))', 'component.create()'), +]) +def test_req_qml017_ac04_unknown_overload_or_creation_cannot_prove_root(tmp_path, load, create): + """Mode, context, conditional, repeated and unavailable loads keep no root target.""" + result = corpus(tmp_path, 'QQmlEngine engine; QQmlComponent component(&engine); ' + + load + '; auto root = ' + create + '; root->property("value");') + access = sites(result, "qml_access", "property")[0] + assert qt_metadata(access)["status"] != "resolved" and not qt_metadata(access).get("target_id") + + +@pytest.mark.parametrize("mode", ["Asynchronous", "PreferSynchronous"]) +@pytest.mark.parametrize("parent", ["", ", nullptr"]) +def test_req_qml017_ac01_constructor_mode_needs_sdk_type_authority(tmp_path, mode, parent): + """A global SDK receiver cannot lend authority to a shadowed enum owner.""" + result = corpus(tmp_path, 'QQmlEngine engine; using QQmlComponent = Fake; ' + f'::QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"), QQmlComponent::{mode}{parent}); ' + 'auto root = component.create(); root->property("value");', + 'struct Fake { enum CompilationMode { Asynchronous, PreferSynchronous }; };') + assert qt_metadata(sites(result, "qml_load")[0])["loader_supported"] is False + assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id") + + +@pytest.mark.parametrize("wrapper", ["QString", "QLatin1String", "QByteArray"]) +@pytest.mark.parametrize("shadow", ["alias", "callable", "global_sdk"]) +def test_req_qml017_ac01_string_wrapper_requires_source_authority(tmp_path, wrapper, shadow): + """Written wrapper arguments cannot prove a value returned by custom code.""" + declaration = (f'using {wrapper} = Fake;' if shadow == "alias" else + f'::{wrapper} {wrapper}(const char*) {{ return ::{wrapper}("qrc:/ui/Other.qml"); }}' + if shadow == "callable" else f'class {wrapper} {{}};') + spelling = "::" + wrapper if shadow == "global_sdk" else wrapper + result = analysis(tmp_path, { + "access.cpp": 'class Fake {}; namespace Public { ' + declaration + ' void use() { ' + f'QQmlApplicationEngine engine({spelling}("qrc:/ui/Main.qml")); ' + 'auto root = engine.rootObjects().first(); root->property("value"); } }', + "Main.qml": "import QtQml\nQtObject { property int value: 1 }", + "Other.qml": "import QtQml\nQtObject { property int value: 2 }", + "ui.qrc": 'Main.qmlOther.qml', + }) + load = qt_metadata(sites(result, "qml_load")[0]) + access = qt_metadata(sites(result, "qml_access", "property")[0]) + if shadow == "global_sdk": + assert load["status"] == access["status"] == "resolved" + else: + assert not load.get("target_id") and not access.get("target_id") diff --git a/tests/test_qt_loader_header_shadows.py b/tests/test_qt_loader_header_shadows.py new file mode 100644 index 0000000000..7a3d273ba4 --- /dev/null +++ b/tests/test_qt_loader_header_shadows.py @@ -0,0 +1,203 @@ +"""INC-QML-24: an accepted included forward class is not external SDK proof.""" +import copy +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.extractors.qt_cpp_syntax import read_cpp +from graphify.extractors.qt_cpp_type_aliases import IncludedAliasShadows +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated +from tests.test_qt_semantic_correction_updates import products + + +FORMS = [ + ("QQmlApplicationEngine", 'QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), + ("QQmlApplicationEngine", 'QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), + ("QQmlComponent", 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"));', 'component.create()'), + ("QQmlComponent", 'QQmlEngine engine; QQmlComponent component(&engine); component.loadUrl(QUrl("qrc:/ui/Main.qml"));', 'component.create()'), + ("QQmlEngine", 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"));', 'component.create()'), + ("QQuickView", 'QQuickView view; view.setSource(QUrl("qrc:/ui/Main.qml"));', 'view.rootObject()'), + ("QUrl", 'QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), + ("QString", 'QQmlApplicationEngine engine(QString("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), +] + + +def fixture(root, form=FORMS[1], *, header="", include=True): + _, load, handle = form + sources = { + "shadow.h": header, + "access.cpp": ('#include "shadow.h"\n' if include else '') + + 'void use() { ' + load + f' auto root={handle}; root->property("value"); }}', + "Main.qml": 'import QtQml\nQtObject { property int value: 1 }', + "ui.qrc": 'Main.qml', + "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n', + } + return analysis(root, sources) + + +@pytest.mark.parametrize("form", FORMS) +@pytest.mark.parametrize("shadow", [False, True]) +def test_req_qml017_ac01_included_forward_class_blocks_sdk_loader_and_wrapper_authority(tmp_path, form, shadow): + sdk, _, _ = form + result = fixture(tmp_path, form, header=f"class {sdk};" if shadow else "// no source type shadow") + access = sites(result, "qml_access", "property")[0] + metadata = qt_metadata(access) + graph = build_from_json(result, root=tmp_path, directed=True) + output = tmp_path / "proof.json" + assert to_json(graph, {}, output, force=True) + payload = json.loads(output.read_text(encoding="utf-8")) + restored = load_node_link_graph(payload) + span = metadata["span"] + assert (tmp_path / "access.cpp").read_bytes()[span["start_byte"]:span["end_byte"]] == b'root->property("value")' + links = [edge for edge in payload["links"] if edge["source"] == access["id"] + and edge.get("context") == "qt_cpp_qml_property_read"] + if shadow: + assert not metadata.get("target_id") and not links + # The generic source forward survives even though no SDK/API target is supplied. + assert any(node.get("metadata", {}).get("cpp_class", {}).get("is_definition") is False + and node["source_file"] == "shadow.h" for node in result["nodes"]) + else: + assert metadata["status"] == "resolved" and len(links) == 1 + assert restored.has_edge(access["id"], metadata["target_id"]) + assert not restored.has_edge(metadata["target_id"], access["id"]) + + +@pytest.mark.parametrize("header,include", [ + ("class QQmlApplicationEngine;", False), + ("namespace Other { class QQmlApplicationEngine; }", True), +]) +def test_req_qml017_ac01_unincluded_and_other_namespace_forwards_cannot_retarget_sdk(tmp_path, header, include): + result = fixture(tmp_path, header=header, include=include) + access = sites(result, "qml_access", "property")[0] + assert qt_metadata(access)["status"] == "resolved" and qt_metadata(access)["target_id"] + + +@pytest.mark.parametrize("corruption", ["transport", "span", "line", "origin", "absolute", "outside"]) +def test_req_qml017_ac04_included_class_shadow_rejects_corrupt_source_provenance(tmp_path, corruption): + result = fixture(tmp_path, header="class QQmlApplicationEngine;") + shadow = copy.deepcopy(next(node for node in result["nodes"] + if node["source_file"] == "shadow.h" and node.get("metadata", {}).get("cpp_class"))) + fact = shadow["metadata"]["cpp_class"] + if corruption == "transport": + fact["qualified_name_b64"] = "invalid!" + elif corruption == "span": + fact["span"]["start_row"] = True + elif corruption == "line": + shadow["source_location"] = "L99" + elif corruption == "origin": + shadow["_origin"] = "semantic" + else: + shadow["source_file"] = "/outside/shadow.h" if corruption == "absolute" else "../shadow.h" + before = copy.deepcopy(shadow) + with pytest.raises(ValueError, match="QT_METADATA"): + IncludedAliasShadows(read_cpp(tmp_path / "access.cpp", tmp_path), [shadow]) + assert shadow == before + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml017_ac04_header_only_forward_edits_match_cold_warm_and_restore(tmp_path, monkeypatch, operation): + fixture(tmp_path, header="// no source shadow") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + source = tmp_path / "shadow.h" + original_cpp = (tmp_path / "access.cpp").read_bytes() + initial_targets = [qt_metadata(data).get("target_id") for _, data in initial.nodes(data=True) + if qt_metadata(data).get("kind") == "qml_access"] + assert len(initial_targets) == 1 and initial_targets[0] + source.write_text("class QQmlApplicationEngine;", encoding="utf-8") + rejected = run(tmp_path, monkeypatch, operation, [source]) + assert all(not qt_metadata(data).get("target_id") for _, data in rejected.nodes(data=True) + if qt_metadata(data).get("kind") == "qml_access") + assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".rejected")) + assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".rejected")) + assert unrelated(rejected) == unrelated(initial) + source.write_text("// no source shadow", encoding="utf-8") + restored = run(tmp_path, monkeypatch, operation, [source]) + assert normalized(restored) == normalized(initial) == normalized(clean(tmp_path, tmp_path / ".restored")) + assert (tmp_path / "access.cpp").read_bytes() == original_cpp + before_repeat = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(restored) + assert products(tmp_path) == before_repeat + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("fault", ["parse", "publication"]) +def test_req_qml017_ac04_header_shadow_failure_preserves_products_and_retries(tmp_path, monkeypatch, operation, fault): + import graphify.watch as watch + + fixture(tmp_path, header="// no source shadow") + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + source = tmp_path / "shadow.h" + before = products(tmp_path) + source.write_text("class QQmlApplicationEngine;", encoding="utf-8") + replace = watch.os_replace_with_fallback + + def fail_graph(start, destination): + if destination.name == "graph.json": + raise PermissionError("public header-shadow publication failure") + return replace(start, destination) + + with monkeypatch.context() as failure: + if fault == "parse": + source.write_text("class QQmlApplicationEngine; void broken( {", encoding="utf-8") + else: + failure.setattr(watch, "os_replace_with_fallback", fail_graph) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, failure, operation, [source]) + assert rejected.value.code == 1 + else: + assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + assert products(tmp_path) == before + source.write_text("class QQmlApplicationEngine;", encoding="utf-8") + recovered = run(tmp_path, monkeypatch, operation, [source]) + assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".recovered")) != normalized(initial) + assert all(not qt_metadata(data).get("target_id") for _, data in recovered.nodes(data=True) + if qt_metadata(data).get("kind") == "qml_access") + current = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered) + assert products(tmp_path) == current + + +@pytest.mark.parametrize("visible", [False, True]) +def test_req_qml017_ac01_transitive_header_shadow_requires_prior_literal_include(tmp_path, visible): + form = FORMS[1] + fixture(tmp_path, form, header="class QQmlApplicationEngine;") + access = (tmp_path / "access.cpp").read_text(encoding="utf-8").replace('#include "shadow.h"\n', '') + include = '#include "bridge.h"\n' + result = analysis(tmp_path, {"access.cpp": include + access if visible else access + "\n" + include, + "bridge.h": '#include "shadow.h"\n', + "shadow.h": "class QQmlApplicationEngine;", + "Main.qml": 'import QtQml\nQtObject { property int value: 1 }', + "ui.qrc": 'Main.qml'}) + metadata = qt_metadata(sites(result, "qml_access", "property")[0]) + assert bool(metadata.get("target_id")) is not visible + + +def test_req_qml017_ac04_class_shadow_walk_overflow_rejects_instead_of_discarding_header(tmp_path): + """No header is opened or guessed by lookup; accepted include paths own its bound.""" + result = fixture(tmp_path, header="class QQmlApplicationEngine;") + shadow = copy.deepcopy(next(node for node in result["nodes"] + if node["source_file"] == "shadow.h" and node.get("metadata", {}).get("cpp_class"))) + # Test the public dependency snapshot at its actual traversal seam. It is + # already accepted source metadata, not a fake resolution or parser result. + includes = [] + for index in range(129): + name, target = ("shadow.h" if index == 0 else f"part{index}.h"), f"part{index + 1}.h" + includes.append({"id": f"include{index}", "label": "Qt type_include: " + target, + "source_file": name, "source_location": "L1-L1", "file_type": "code", "_origin": "ast", + "metadata": {"qt": {"contract_version": 1, "kind": "type_include", "raw_name": target, + "value": target, "span": {"start_byte": 0, "end_byte": 24, + "start_row": 0, "end_row": 0, "start_column": 0, "end_column": 24}}}}) + index = IncludedAliasShadows(read_cpp(tmp_path / "access.cpp", tmp_path), [shadow, *includes]) + position = (tmp_path / "access.cpp").read_bytes().index(b"engine.load") + from graphify.extractors.qt_cpp_syntax import QtCppError + with pytest.raises(QtCppError, match="128"): + index.class_declared((), "unrelated", position) diff --git a/tests/test_qt_loader_provenance.py b/tests/test_qt_loader_provenance.py new file mode 100644 index 0000000000..c60ba448d7 --- /dev/null +++ b/tests/test_qt_loader_provenance.py @@ -0,0 +1,61 @@ +"""REQ-QML-017 literal loader admission through real facade/build/JSON provenance.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites + + +@pytest.mark.parametrize("loader", ["engine_load", "engine_url_constructor", "component_load_url"]) +def test_req_qml017_ac01_literal_loader_keeps_component_and_property_provenance(tmp_path, loader): + """Equivalent literal loaders must preserve exact source and persisted joins.""" + url = (tmp_path / "Main.qml").as_uri() + loader_expressions = { + "engine_load": f'engine.load(QUrl("{url}"))', + "engine_url_constructor": f'QQmlApplicationEngine engine(QUrl("{url}"))', + "component_load_url": f'component.loadUrl(QUrl("{url}"))', + } + expression = loader_expressions[loader] + forms = { + "engine_load": f'QQmlApplicationEngine engine; {expression}; auto root = engine.rootObjects().first();', + "engine_url_constructor": f'{expression}; auto root = engine.rootObjects().first();', + "component_load_url": f'QQmlEngine engine; QQmlComponent component(&engine); {expression}; auto root = component.create();', + } + source = 'void use() { ' + forms[loader] + ' root->property("value"); }' + result = analysis(tmp_path, { + "access.cpp": source, + "Main.qml": 'import QtQml\nQtObject { property int value: 1 }', + }) + graph = build_from_json(result, root=tmp_path) + output = tmp_path / "proof.json" + assert to_json(graph, {}, str(output), force=True) + reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + + # Check the source-owning loader occurrence, not a URL-less constructor or + # a generic C++ call that lacks special component provenance. + original = source.encode() + loaders = [node for node in sites(result, "qml_load") + if original[qt_metadata(node)["span"]["start_byte"]: + qt_metadata(node)["span"]["end_byte"]] == expression.encode()] + assert len(loaders) == 1, { + "loader": loader, + "recognized_operations": [qt_metadata(node)["operation"] for node in sites(result, "qml_load")], + } + metadata = qt_metadata(loaders[0]) + assert metadata["status"] == "resolved" + assert metadata["target_id"] in reloaded + assert reloaded.has_edge(loaders[0]["id"], metadata["target_id"]) + assert reloaded.edges[loaders[0]["id"], metadata["target_id"]]["context"] == "qt_cpp_qml_load" + + access = sites(result, "qml_access", "property")[0] + metadata = qt_metadata(access) + assert metadata["status"] == "resolved" + assert original[metadata["span"]["start_byte"]:metadata["span"]["end_byte"]] == b'root->property("value")' + assert reloaded.has_edge(access["id"], metadata["target_id"]) + assert reloaded.edges[access["id"], metadata["target_id"]]["context"] == "qt_cpp_qml_property_read" diff --git a/tests/test_qt_loader_providers.py b/tests/test_qt_loader_providers.py new file mode 100644 index 0000000000..36f2e5633a --- /dev/null +++ b/tests/test_qt_loader_providers.py @@ -0,0 +1,97 @@ +"""REQ-QML-017-AC01/AC03: component loaders retain exact engine providers.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_loader_updates import assert_context + +BACKEND = '''class Backend : public QObject { Q_OBJECT Q_PROPERTY(int count READ count) +public: int count() { return 1; } }; +''' +LOAD = 'component.loadUrl(QUrl("qrc:/ui/Main.qml")); auto root = component.create();' + + +def published(root, source, *, bare=False, directed=False): + """The producer, resolver, strict build guard and actual JSON writer all run.""" + result = analysis(root, { + "backend.h": BACKEND, + "access.cpp": '#include "backend.h"\n' + source, + "Main.qml": 'import QtQml\nQtObject { property int value: ' + ("count" if bare else "backend.count") + ' }', + "ui.qrc": 'Main.qml', + "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h access.cpp)\n', + }) + graph = build_from_json(result, root=root, directed=directed) + output = root / "proof.json" + assert to_json(graph, {}, str(output), force=True) + return result, load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + + +@pytest.mark.parametrize("engine_type", ["QQmlEngine", "QQmlApplicationEngine"]) +@pytest.mark.parametrize("context_object", [False, True]) +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml017_ac03_component_load_url_uses_its_exact_engine_provider(tmp_path, engine_type, context_object, directed): + """Engine-only construction associates one later URL load with its provider.""" + exposure = 'setContextObject(backend)' if context_object else 'setContextProperty("backend", backend)' + source = f'''void use(Backend *backend) {{ {engine_type} engine; + engine.rootContext()->{exposure}; QQmlComponent component(&engine); {LOAD} }}''' + result, graph = published(tmp_path, source, bare=context_object, directed=directed) + loads = sites(result, "qml_load") + assert len(loads) == 1 and qt_metadata(loads[0])["operation"] == "loadUrl" + loader = qt_metadata(loads[0]) + expose = qt_metadata(sites(result, "context_exposure")[0]) + binding = sites(result, "context_binding")[0] + bound = qt_metadata(binding) + assert loader["status"] == bound["status"] == "resolved" + assert loader["engine_declaration_id"] == expose["engine_declaration_id"] == bound["engine_declaration_id"] + assert len(bound["engine_declaration_id"]) == 64 + assert bound["provider_declaration_id"] == expose["provider_declaration_id"] + assert bound["component_target_id"] == loader["target_id"] + assert_context(graph, loads[0]["id"], loader["target_id"], "qt_cpp_qml_load") + assert_context(graph, binding["id"], bound["component_target_id"], "qt_context_exposure") + access = sites(result, "context_access")[0] + target = qt_metadata(access)["target_id"] + assert graph.nodes[target]["source_file"] == "backend.h" + assert_context(graph, access["id"], target, "qt_context_member") + if directed: + assert not graph.has_edge(target, access["id"]) + + +@pytest.mark.parametrize("body", [ + '{ QQmlEngine engine; engine.rootContext()->setContextProperty("backend", backend); } ' + '{ QQmlEngine engine; QQmlComponent component(&engine); ' + LOAD + ' }', + 'QQmlEngine engine; engine.rootContext()->setContextProperty("backend", backend); ' + '{ QQmlEngine engine; QQmlComponent component(&engine); ' + LOAD + ' }', +]) +def test_req_qml017_ac03_component_engine_shadow_cannot_borrow_another_provider(tmp_path, body): + """A valid component load must not select an exposure by engine spelling.""" + result, graph = published(tmp_path, 'void use(Backend *backend) { ' + body + ' }') + loads = sites(result, "qml_load") + assert len(loads) == 1 and qt_metadata(loads[0])["status"] == "resolved" + assert not sites(result, "context_binding") and not sites(result, "context_access") + assert not [edge for _, _, edge in graph.edges(data=True) + if edge.get("context") in {"qt_context_exposure", "qt_context_member"}] + + +@pytest.mark.parametrize("body", [ + 'QQmlEngine *engine = new QQmlEngine; engine->rootContext()->setContextProperty("backend", backend); ' + 'engine = replacement; QQmlComponent component(engine); ' + LOAD, + 'QQmlEngine engine; engine.rootContext()->setContextProperty("backend", backend); ' + 'if (enabled) { QQmlComponent component(&engine); ' + LOAD + ' }', + 'QQmlEngine engine; engine.rootContext()->setContextProperty("backend", backend); ' + 'QQmlComponent component(factory()); ' + LOAD, +]) +def test_req_qml017_ac03_component_engine_assignment_condition_and_factory_reject(tmp_path, body): + """Unproved constructor engine associations publish no component/provider targets.""" + source = 'void use(Backend *backend, QQmlEngine *replacement) { ' + body + ' }' + result, graph = published(tmp_path, source) + assert not sites(result, "context_binding") and not sites(result, "context_access") + assert not [md for node in sites(result, "qml_load") if (md := qt_metadata(node))["status"] == "resolved"] + assert not [edge for _, _, edge in graph.edges(data=True) + if edge.get("context") in {"qt_cpp_qml_load", "qt_context_exposure", "qt_context_member"}] diff --git a/tests/test_qt_loader_sdk_declarations.py b/tests/test_qt_loader_sdk_declarations.py new file mode 100644 index 0000000000..457bfe9a2a --- /dev/null +++ b/tests/test_qt_loader_sdk_declarations.py @@ -0,0 +1,136 @@ +"""Loader SDK authority cannot come from an incomplete source declaration.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated +from tests.test_qt_loader_updates import assert_load, facts, fixture, products, source + + +@pytest.mark.parametrize("sdk_name,load,root", [ + ("QQmlApplicationEngine", 'QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), + ("QQmlApplicationEngine", 'QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), + ("QQmlComponent", 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"));', 'component.create()'), + ("QQmlComponent", 'QQmlEngine engine; QQmlComponent component(&engine); component.loadUrl(QUrl("qrc:/ui/Main.qml"));', 'component.create()'), + ("QQmlEngine", 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"));', 'component.create()'), + ("QQuickView", 'QQuickView view; view.setSource(QUrl("qrc:/ui/Main.qml"));', 'view.rootObject()'), + ("QUrl", 'QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), + ("QString", 'QQmlApplicationEngine engine(QString("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'), +]) +@pytest.mark.parametrize("shadow", [False, True]) +def test_req_qml017_ac01_incomplete_sdk_declaration_cannot_authorize_loader(tmp_path, sdk_name, load, root, shadow): + """Paired external SDK controls and unknown source classes reach JSON reload.""" + source = (f'class {sdk_name};\n' if shadow else '') + 'void use() { ' + load + f' auto root={root}; root->property("value"); }}' + result = analysis(tmp_path, { + 'access.cpp': source, + 'Main.qml': 'import QtQml\nQtObject { property int value: 1 }', + 'ui.qrc': 'Main.qml', + }) + access = sites(result, 'qml_access', 'property')[0] + graph = build_from_json(result) + output = tmp_path / 'graph.json' + to_json(graph, {}, output) + restored = load_node_link_graph(json.loads(output.read_text(encoding='utf-8'))) + target = qt_metadata(access).get('target_id') + if shadow: + assert not target + assert not [data for _, _, data in restored.edges(data=True) + if data.get('_src') == access['id'] and data.get('context') == 'qt_cpp_qml_property_read'] + else: + assert qt_metadata(access)['status'] == 'resolved' and target in restored + + +@pytest.mark.parametrize('prefix', ['if (ready) ', 'engine = factory(); ']) +def test_req_qml017_ac04_uncertain_sdk_use_retains_observed_loader(tmp_path, prefix): + """Static SDK type authority does not establish conditional/reassigned identity.""" + result = analysis(tmp_path, { + 'access.cpp': 'void use() { QQmlApplicationEngine engine; ' + prefix + + 'engine.load(QUrl("qrc:/ui/Main.qml")); }', + 'Main.qml': 'import QtQml\nQtObject {}', + 'ui.qrc': 'Main.qml', + }) + loads = sites(result, 'qml_load') + assert len(loads) == 1 + metadata = qt_metadata(loads[0]) + assert metadata['status'] == 'dynamic' and not metadata.get('target_id') + + +@pytest.mark.parametrize('operation', ['manual', 'watch']) +@pytest.mark.parametrize('form,sdk_name', [('engine_constructor', 'QQmlApplicationEngine'), + ('component_load_url', 'QQmlComponent')]) +def test_req_qml017_ac04_loader_forward_edits_retire_and_restore_targets(tmp_path, monkeypatch, operation, form, sdk_name): + """Real SDK→forward→SDK→removed updates agree with complete fresh graphs.""" + cpp, expression = fixture(tmp_path, form) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('GRAPHIFY_NO_TIPS', '1') + original = cpp.read_bytes() + initial = run(tmp_path, monkeypatch, operation) + assert_load(initial, original, expression) + retained = unrelated(initial) + assert normalized(initial) == normalized(clean(tmp_path, tmp_path / '.cold')) + cpp.write_bytes(f'class {sdk_name};\n'.encode() + original) + rejected = run(tmp_path, monkeypatch, operation, [cpp]) + assert all(not md.get('target_id') for md in facts(rejected, 'qml_access').values()) + assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / '.rejected')) + assert unrelated(rejected) == retained + cpp.write_bytes(original) + restored = run(tmp_path, monkeypatch, operation, [cpp]) + assert normalized(restored) == normalized(initial) + assert normalized(restored) == normalized(clean(tmp_path, tmp_path / '.restored')) + before_repeat = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [cpp])) == normalized(restored) + assert products(tmp_path) == before_repeat + cpp.write_bytes(source(form, removed=True)[0].encode()) + removed = run(tmp_path, monkeypatch, operation, [cpp]) + assert all(not md.get('target_id') for md in facts(removed, 'qml_access').values()) + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / '.removed')) + assert unrelated(removed) == retained + + +@pytest.mark.parametrize('operation', ['manual', 'watch']) +@pytest.mark.parametrize('fault', ['parse', 'publication']) +def test_req_qml017_ac04_loader_forward_failure_retains_products_and_recovers(tmp_path, monkeypatch, operation, fault): + """Authority edits cannot advance accepted products through a failed update.""" + import graphify.watch as watch + cpp, _ = fixture(tmp_path, 'engine_constructor') + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('GRAPHIFY_NO_TIPS', '1') + original = cpp.read_bytes() + initial = run(tmp_path, monkeypatch, operation) + accepted = products(tmp_path) + rejected_source = b'class QQmlApplicationEngine;\n' + original + cpp.write_bytes(rejected_source) + real_replace = watch.os_replace_with_fallback + + def fail_replace(start, destination): + if str(destination).endswith('graph.json'): + raise PermissionError('injected authority publication failure') + return real_replace(start, destination) + + with monkeypatch.context() as failure: + if fault == 'parse': + cpp.write_bytes(rejected_source + b'void broken( {') + else: + failure.setattr(watch, 'os_replace_with_fallback', fail_replace) + if operation == 'manual': + with pytest.raises(SystemExit) as rejected: + run(tmp_path, failure, operation, [cpp]) + assert rejected.value.code == 1 + else: + assert not watch._rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True) + assert products(tmp_path) == accepted + cpp.write_bytes(rejected_source) + recovered = run(tmp_path, monkeypatch, operation, [cpp]) + assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / '.recovered')) + assert normalized(recovered) != normalized(initial) + assert all(not md.get('target_id') for md in facts(recovered, 'qml_access').values()) + repeated = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered) + assert products(tmp_path) == repeated diff --git a/tests/test_qt_loader_updates.py b/tests/test_qt_loader_updates.py new file mode 100644 index 0000000000..41671e8f4b --- /dev/null +++ b/tests/test_qt_loader_updates.py @@ -0,0 +1,234 @@ +"""REQ-QML-017-AC01/AC04: literal loader provenance and durable refresh.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.affected import affected_nodes, load_graph +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_consumer_helpers import cli as consumer_cli +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated + + +def source(form, target="Main.qml", *, removed=False): + """Only the admitted static loader forms vary; no Qt runtime is invoked.""" + url = f'QUrl("qrc:/ui/{target}")' + if form == "engine_constructor": + load = f'QQmlApplicationEngine engine({url})' + body = 'QQmlApplicationEngine engine' if removed else load + body += '; auto root = engine.rootObjects().first();' + else: + load = f'component.loadUrl({url})' + body = 'QQmlEngine engine; QQmlComponent component(&engine); ' + body += '' if removed else load + '; ' + body += 'auto root = component.create();' + return 'void use() { ' + body + ' root->property("value"); }\n', load + + +def fixture(root, form): + content, expression = source(form) + files = { + "loader.cpp": content, + "Main.qml": 'import QtQml\nQtObject { property int value: 1 }\n', + "Alt.qml": 'import QtQml\nQtObject { property int value: 2 }\n', + "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml Alt.qml SOURCES loader.cpp)\n', + "ui.qrc": 'Main.qmlAlt.qml', + "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n', + } + for name, text in files.items(): + (root / name).write_bytes(text.encode()) + return root / "loader.cpp", expression + + +def facts(graph, kind): + return {identity: qt_metadata(data) for identity, data in graph.nodes(data=True) + if qt_metadata(data).get("kind") == kind} + + +def assert_context(graph, source, target, context): + """CLI/watch can retain parallel edges; inspect the exact logical mechanism.""" + data = graph.get_edge_data(source, target) + assert data is not None + records = data.values() if graph.is_multigraph() else [data] + matches = [edge for edge in records if edge.get("context") == context] + assert len(matches) == 1 + assert (matches[0].get("_src", source), matches[0].get("_tgt", target)) == (source, target) + + +def assert_load(graph, original, expression, filename="Main.qml"): + """The URL-owning source occurrence reaches the exact accepted component.""" + matches = [(identity, md) for identity, md in facts(graph, "qml_load").items() + if original[md["span"]["start_byte"]:md["span"]["end_byte"]] == expression.encode()] + assert len(matches) == 1, [(md.get("operation"), md.get("status")) for md in facts(graph, "qml_load").values()] + assert len(facts(graph, "qml_load")) == 1 + identity, metadata = matches[0] + assert metadata["status"] == "resolved" and len(metadata["receiver_declaration_id"]) == 64 + target = metadata["target_id"] + assert graph.nodes[target]["source_file"] == filename and graph.has_edge(identity, target) + assert_context(graph, identity, target, "qt_cpp_qml_load") + roots = facts(graph, "qml_root") + assert len(roots) == 1 and all(md["status"] == "resolved" for md in roots.values()) + assert all(graph.has_edge(root, md["target_id"]) for root, md in roots.items()) + access, md = next(iter(facts(graph, "qml_access").items())) + assert md["status"] == "resolved" and graph.nodes[md["target_id"]]["source_file"] == filename + assert graph.has_edge(access, md["target_id"]) + assert_context(graph, access, md["target_id"], "qt_cpp_qml_property_read") + + +def products(root): + output = root / "graphify-out" + return {name: (output / name).read_bytes() for name in + ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")} + + +@pytest.mark.parametrize("form", ["engine_constructor", "component_load_url"]) +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml017_ac04_loader_edits_match_cold_warm_manual_and_watch(tmp_path, monkeypatch, form, operation): + """URL/source removal and metadata/QML edits retire stale persisted joins.""" + cpp, expression = fixture(tmp_path, form) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + cold = clean(tmp_path, tmp_path / ".cold") + assert_load(cold, cpp.read_bytes(), expression) + assert normalized(clean(tmp_path, tmp_path / ".cold")) == normalized(cold) + initial = run(tmp_path, monkeypatch, operation) + assert normalized(initial) == normalized(cold) + untouched = unrelated(initial) + + content, expression = source(form, "Alt.qml") + cpp.write_bytes(content.encode()) + moved = run(tmp_path, monkeypatch, operation, [cpp]) + assert_load(moved, cpp.read_bytes(), expression, "Alt.qml") + assert normalized(moved) == normalized(clean(tmp_path, tmp_path / ".moved")) + load_ids, access_ids = set(facts(moved, "qml_load")), set(facts(moved, "qml_access")) + unchanged_cpp = cpp.read_bytes() + + # A metadata-only alias change invalidates the unchanged native loader. + resource = tmp_path / "ui.qrc" + valid_resource = resource.read_bytes() + resource.write_bytes(valid_resource.replace(b'alias="Alt.qml"', b'alias="renamed.qml"')) + missing = run(tmp_path, monkeypatch, operation, [resource]) + assert set(facts(missing, "qml_load")) == load_ids and set(facts(missing, "qml_access")) == access_ids + assert cpp.read_bytes() == unchanged_cpp + assert all(md["status"] != "resolved" and not md.get("target_id") for md in facts(missing, "qml_load").values()) + assert all(md["status"] != "resolved" and not md.get("target_id") for md in facts(missing, "qml_access").values()) + assert normalized(missing) == normalized(clean(tmp_path, tmp_path / ".missing")) + resource.write_bytes(valid_resource) + assert normalized(run(tmp_path, monkeypatch, operation, [resource])) == normalized(moved) + + # The source loader/root survive a QML-only member deletion, but its access + # must lose the previous target and projected relationship. + qml = tmp_path / "Alt.qml" + qml.write_bytes(qml.read_bytes().replace(b"int value:", b"int replacement:")) + changed = run(tmp_path, monkeypatch, operation, [qml]) + assert set(facts(changed, "qml_load")) == load_ids and set(facts(changed, "qml_access")) == access_ids + assert cpp.read_bytes() == unchanged_cpp + assert all(md["status"] == "resolved" for md in facts(changed, "qml_load").values()) + for identity, md in facts(changed, "qml_access").items(): + assert md["status"] != "resolved" and not md.get("target_id") + assert not any(data.get("_src", start) == identity and data.get("relation") == "uses" + for start, _, data in changed.edges(data=True)) + assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".qml-edit")) + cpp.write_bytes(source(form, removed=True)[0].encode()) + removed = run(tmp_path, monkeypatch, operation, [cpp]) + assert not [md for md in facts(removed, "qml_load").values() if md["status"] == "resolved"] + assert all(md["status"] != "resolved" for md in facts(removed, "qml_access").values()) + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed")) + assert unrelated(removed) == untouched + before = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed) + assert products(tmp_path) == before + + +@pytest.mark.parametrize("form", ["engine_constructor", "component_load_url"]) +@pytest.mark.parametrize("operation", ["manual", "force", "watch"]) +def test_req_qml017_ac04_loader_parse_failure_preserves_products_and_recovers(tmp_path, monkeypatch, form, operation): + """Real malformed input is rejected before all four durable products change.""" + cpp, expression = fixture(tmp_path, form) + monkeypatch.chdir(tmp_path) + mode = "manual" if operation == "force" else operation + initial = run(tmp_path, monkeypatch, mode) + assert_load(initial, cpp.read_bytes(), expression) + valid, accepted = cpp.read_bytes(), products(tmp_path) + cpp.write_bytes(valid + b"void broken({") + if operation == "watch": + from graphify.watch import _rebuild_code + assert not _rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True) + else: + with pytest.raises(SystemExit) as rejected: + if operation == "force": + from tests.test_qt_cpp_upgrade_invalidation import cli + cli(tmp_path, monkeypatch, "update", force=True) + else: + run(tmp_path, monkeypatch, mode, [cpp]) + assert rejected.value.code == 1 + assert products(tmp_path) == accepted + cpp.write_bytes(valid) + repaired = run(tmp_path, monkeypatch, mode, [cpp]) + assert normalized(repaired) == normalized(initial) + # Repair updates source stat/provenance; the subsequent no-change operation + # must preserve that repaired durable state, not its historical timestamps. + repaired_products = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(initial) + assert products(tmp_path) == repaired_products + + +@pytest.mark.parametrize("form", ["engine_constructor", "component_load_url"]) +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml017_ac04_loader_write_failure_preserves_products_and_retries(tmp_path, monkeypatch, form, operation): + """The real graph replacement can fail after successful parser/resolver work.""" + import graphify.watch as watch + cpp, expression = fixture(tmp_path, form) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + assert_load(initial, cpp.read_bytes(), expression) + accepted = products(tmp_path) + content, expression = source(form, "Alt.qml") + cpp.write_bytes(content.encode()) + real_replace = watch.os_replace_with_fallback + + def fail_replace(start, destination): + if str(destination).endswith("graph.json"): + raise PermissionError("injected loader graph replacement failure") + return real_replace(start, destination) + + with monkeypatch.context() as failure: + failure.setattr(watch, "os_replace_with_fallback", fail_replace) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, failure, operation, [cpp]) + assert rejected.value.code == 1 + else: + assert not watch._rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True) + assert products(tmp_path) == accepted + recovered = run(tmp_path, monkeypatch, operation, [cpp]) + assert_load(recovered, cpp.read_bytes(), expression, "Alt.qml") + assert normalized(recovered) != normalized(initial) + assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".recovered")) + repeated = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered) + assert products(tmp_path) == repeated + + +@pytest.mark.parametrize("form", ["engine_constructor", "component_load_url"]) +def test_req_qml017_ac04_loader_directed_reload_query_and_affected_keep_endpoints(tmp_path, monkeypatch, capsys, form): + """Actual persisted directed consumers retain the precise loader/member path.""" + cpp, expression = fixture(tmp_path, form) + from graphify.extract import collect_files, extract + result = extract(collect_files(tmp_path, root=tmp_path), root=tmp_path, cache_root=tmp_path, parallel=False) + assert not result["failed_sources"] and not result["qml_failures"] + graph = build_from_json(result, root=tmp_path, directed=True) + output = tmp_path / "directed.json" + assert to_json(graph, {}, str(output), force=True) + loaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert_load(loaded, cpp.read_bytes(), expression) + identity, metadata = next(iter(facts(loaded, "qml_access").items())) + assert not loaded.has_edge(metadata["target_id"], identity) + assert "Main.qml" in consumer_cli(monkeypatch, capsys, output, "query", identity) + assert "loader.cpp" in consumer_cli(monkeypatch, capsys, output, "explain", identity) + hits = {hit.node_id for hit in affected_nodes(load_graph(output), metadata["target_id"], relations=["uses"], depth=1)} + assert identity in hits diff --git a/tests/test_qt_mcp_consumers.py b/tests/test_qt_mcp_consumers.py new file mode 100644 index 0000000000..251ea5fe1b --- /dev/null +++ b/tests/test_qt_mcp_consumers.py @@ -0,0 +1,78 @@ +"""QML-013-AC04 actual in-process MCP HTTP calls agree with saved-graph CLI.""" +import pytest + +pytest.importorskip("mcp", reason="QML-013-AC04 requires the MCP extra") +pytest.importorskip("starlette", reason="QML-013-AC04 requires the MCP HTTP dependency") + +from starlette.testclient import TestClient # noqa: E402 +from graphify.build import build_from_json # noqa: E402 +from graphify.export import to_json # noqa: E402 +from graphify.serve import _build_http_app # noqa: E402 +from tests.qt_analysis_helpers import analysis # noqa: E402 +from tests.qt_consumer_helpers import cli, saved_graph # noqa: E402 +from tests.test_qt_signals_slots import SOURCE # noqa: E402 + +HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + + +def initialize(client): + response = client.post("/mcp", headers=HEADERS, json={"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "qt-acceptance", "version": "1"}}}) + assert response.status_code == 200 + headers = {**HEADERS, "mcp-session-id": response.headers.get("mcp-session-id")} + client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"}) + return headers + + +def tool(client, headers, name, arguments, rid): + response = client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "id": rid, "method": "tools/call", + "params": {"name": name, "arguments": arguments}}) + assert response.status_code == 200 + result = response.json()["result"] + assert not result.get("isError"), result + return result["content"][0]["text"].strip() + + +def test_mcp_query_node_and_neighbors_match_cli_source_confidence(tmp_path, monkeypatch, capsys): + """An initialized HTTP session queries persisted scoped facts and evidence.""" + path, _, _, prop, read = saved_graph(tmp_path) + query = cli(monkeypatch, capsys, path, "query", read["id"]) + app = _build_http_app(str(path), json_response=True) + with TestClient(app, base_url="http://127.0.0.1") as client: + headers = initialize(client) + # The CLI defaults to depth two; protocol defaults stay independent. + mcp_query = tool(client, headers, "query_graph", {"question": read["id"], "depth": 2}, 2) + assert mcp_query == query + node = tool(client, headers, "get_node", {"node_id": prop["id"]}, 4) + assert "Main.qml" in node and prop["id"] in node + neighbors = tool(client, headers, "get_neighbors", {"node_id": read["id"]}, 5) + assert "INFERRED" in neighbors and "Main.qml:L" in neighbors + + +def test_mcp_exact_id_path_matches_cli_and_retains_source_evidence(tmp_path, monkeypatch, capsys): + """Opaque canonical IDs select the intended endpoints in both interfaces.""" + path, _, component, prop, _ = saved_graph(tmp_path) + route = cli(monkeypatch, capsys, path, "path", component["id"], prop["id"]) + with TestClient(_build_http_app(str(path), json_response=True), base_url="http://127.0.0.1") as client: + headers = initialize(client) + mcp_path = tool(client, headers, "shortest_path", {"source": component["id"], "target": prop["id"]}, 2) + assert mcp_path == route + assert "Main.qml:L" in mcp_path and "EXTRACTED" in mcp_path + + +def test_qml016_ac04_http_metadata_search_keeps_connection_reference_semantics(tmp_path, monkeypatch, capsys): + """Native Qt facts stay searchable and references cannot become delivery calls.""" + result = analysis(tmp_path, {"events.cpp": SOURCE}) + graph = build_from_json(result, root=tmp_path) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path), force=True) + query = cli(monkeypatch, capsys, path, "query", "QueuedConnection") + endpoint = next(edge["source"] for edge in result["edges"] if edge.get("context") == "qt_connect_signal") + with TestClient(_build_http_app(str(path), json_response=True), base_url="http://127.0.0.1") as client: + headers = initialize(client) + mcp_query = tool(client, headers, "query_graph", {"question": "QueuedConnection", "depth": 2}, 2) + assert mcp_query == query and "Qt connect:" in mcp_query and "events.cpp:L" in mcp_query + references = tool(client, headers, "get_neighbors", {"node_id": endpoint, "relation_filter": "references"}, 3) + assert "[references] [INFERRED]" in references and "events.cpp:L" in references + calls = tool(client, headers, "get_neighbors", {"node_id": endpoint, "relation_filter": "calls"}, 4) + assert "-->" not in calls and "<--" not in calls diff --git a/tests/test_qt_mcp_stdio.py b/tests/test_qt_mcp_stdio.py new file mode 100644 index 0000000000..a34f4db10f --- /dev/null +++ b/tests/test_qt_mcp_stdio.py @@ -0,0 +1,44 @@ +"""Actual stdio MCP session parity against production saved-graph CLI consumers.""" +from __future__ import annotations + +import asyncio +import os +import sys + +import pytest + +pytest.importorskip("mcp", reason="QML-013-AC04 requires the optional MCP SDK") + +from mcp import ClientSession, StdioServerParameters # noqa: E402 +from mcp.client.stdio import stdio_client # noqa: E402 + +from tests.qt_consumer_helpers import cli, saved_graph # noqa: E402 + + +def text_result(result): + assert not result.isError, result + return "\n".join(item.text for item in result.content if item.type == "text").strip() + + +def test_stdio_query_path_and_source_node_agree_with_cli(tmp_path, monkeypatch, capsys): + path, _, component, prop, read = saved_graph(tmp_path) + monkeypatch.chdir(tmp_path) + query = cli(monkeypatch, capsys, path, "query", read["id"]) + route = cli(monkeypatch, capsys, path, "path", component["id"], prop["id"]) + parameters = StdioServerParameters(command=sys.executable, + args=["-X", "utf8", "-m", "graphify.serve", "--graph", str(path)], + env={**os.environ, "PYTHONUTF8": "1"}, cwd=str(tmp_path)) + + async def exercise(): + async with stdio_client(parameters) as (reader, writer): + async with ClientSession(reader, writer) as session: + await session.initialize() + tools = await session.list_tools() + assert {"query_graph", "shortest_path", "get_node"} <= {tool.name for tool in tools.tools} + actual_query = await session.call_tool("query_graph", {"question": read["id"], "depth": 2}) + actual_route = await session.call_tool("shortest_path", {"source": component["id"], "target": prop["id"]}) + actual_node = await session.call_tool("get_node", {"node_id": prop["id"]}) + assert text_result(actual_query) == query + assert text_result(actual_route) == route + assert "Main.qml" in text_result(actual_node) and prop["id"] in text_result(actual_node) + asyncio.run(asyncio.wait_for(exercise(), timeout=25)) diff --git a/tests/test_qt_member_source_links.py b/tests/test_qt_member_source_links.py new file mode 100644 index 0000000000..cd0a16feb4 --- /dev/null +++ b/tests/test_qt_member_source_links.py @@ -0,0 +1,183 @@ +"""REQ-QML-008-AC02 links source occurrences without inventing class authority.""" +import copy +import json +import re + +import pytest + +from graphify.build import build_from_json +from graphify.cluster import cluster +from graphify.export import to_html, to_json +from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp, is_qt_cpp_source +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qt_qml_bridge import build_qt_qml_bridge +from tests.qt_analysis_helpers import analysis, sites + + +HEADER = "class Utility { public: void write(int value); };\n" +METHOD = "void Utility::write(int value) { Q_UNUSED(value) }" +LOCAL = "void collect(int value) { struct Row { int entry; }; Q_UNUSED(value) }" +HINT = "class Utility;\nclass Host : public QObject { Q_OBJECT };\n" + + +def utility(root, filename="utility.cpp", *, hint=False): + sources = {"utility.hpp": HEADER, filename: '#include "utility.hpp"\n' + METHOD + "\n"} + if hint: + sources[filename] = sources[filename].replace("Q_UNUSED(value)", "(void)value;") + sources["host.hpp"] = HINT + result = analysis(root, sources) + member = next(node for node in sites(result, "member") + if qt_metadata(node)["class_name"] == "Utility") + return result, member + + +def occurrence(result, kind): + values = sites(result, kind) + if kind == "member": + values = [node for node in values if qt_metadata(node)["class_name"] == "Utility"] + assert len(values) == 1 + return values[0] + + +def accepted_generic(result): + nodes = copy.deepcopy([node for node in result["nodes"] if not qt_metadata(node)]) + ids = {node["id"] for node in nodes} + edges = copy.deepcopy([edge for edge in result["edges"] + if not qt_metadata(edge) and edge["source"] in ids and edge["target"] in ids]) + return nodes, edges + + +def assert_source_link(result, site, target, original): + metadata = qt_metadata(site) + assert metadata["owner_id"] == target + assert metadata["class_id"] == "" + span = metadata["span"] + assert original[span["start_byte"]:span["end_byte"]] in ( + METHOD.encode(), METHOD.replace("Q_UNUSED(value)", "(void)value;").encode(), b"struct Row { int entry; }") + links = [edge for edge in result["edges"] if edge["source"] == target and edge["target"] == site["id"]] + assert len(links) == 1 + assert links[0]["relation"] == "contains" and links[0]["confidence"] == "EXTRACTED" + assert links[0]["context"] == "qt_source_site" + assert qt_metadata(links[0])["span"] == span + + +@pytest.mark.parametrize("filename,hint", [("utility.cpp", False), ("writer.cpp", False), + ("writer.cpp", True)]) +def test_req_qml008_ac02_plain_member_uses_exact_callable_without_native_class_claim(tmp_path, filename, hint): + result, member = utility(tmp_path, filename, hint=hint) + metadata = qt_metadata(member) + target = metadata["generic_target_id"] + declaration = next(node for node in result["nodes"] if node["id"] == target) + assert metadata["status"] == "resolved" + assert declaration.get("_callable") is True and not declaration.get("_callable_class") + assert not metadata["roles"] and metadata["class_id"] == "" + assert metadata["owner_id"] == target + assert not is_qt_cpp_source(tmp_path / "utility.hpp") + assert not [node for node in sites(result, "class") if qt_metadata(node).get("class_name") == "Utility" + and qt_metadata(node).get("is_definition") is True] + if hint: + assert not is_qt_cpp_source(tmp_path / filename) + assert is_qt_cpp_source(tmp_path / filename, class_names=["Utility"]) + forwards = [node for node in sites(result, "class") if qt_metadata(node)["class_name"] == "Utility"] + assert len(forwards) == 1 and qt_metadata(forwards[0])["is_definition"] is False + assert_source_link(result, member, target, (tmp_path / filename).read_bytes()) + index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path) + assert not index.provider_records and index.module_type("Demo", 1, 0, "Utility").status == "unavailable" + + +def test_req_qml008_ac02_source_link_survives_build_json_and_aggregate(tmp_path): + result, member = utility(tmp_path) + target = qt_metadata(member)["generic_target_id"] + graph = build_from_json(result, root=tmp_path) + assert graph.has_edge(target, member["id"]) and graph.degree(member["id"]) > 0 + groups = cluster(graph) + community = next(values for values in groups.values() if member["id"] in values) + assert target in community and len(community) > 1 + output = tmp_path / "graph.json" + assert to_json(graph, groups, str(output)) + restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + edge = restored.edges[target, member["id"]] + assert (edge["_src"], edge["_tgt"]) == (target, member["id"]) + assert edge["relation"] == "contains" and edge["context"] == "qt_source_site" + assert qt_metadata(dict(restored.nodes[member["id"]])) == qt_metadata(member) + html = tmp_path / "graph.html" + assert to_html(restored, groups, str(html), node_limit=1) + match = re.search(r"const RAW_NODES = (\[.*?\]);", html.read_text(encoding="utf-8"), re.S) + assert match + payload = json.loads(match[1]) + assert not [node for node in payload if node.get("member_count") == 1 and "Qt member" in node["label"]] + + +def test_req_qml008_ac02_local_class_links_to_enclosing_callable_without_type_authority(tmp_path): + result = analysis(tmp_path, {"local.cpp": LOCAL + "\n"}) + local = occurrence(result, "class") + function = next(node for node in result["nodes"] if node.get("_callable") + and node.get("source_file") == "local.cpp" and node["label"] == "collect()") + metadata = qt_metadata(local) + assert metadata["is_definition"] is True and metadata["status"] == "unavailable" + assert metadata["generic_target_id"] == "" and metadata["class_id"] == "" + assert not metadata["is_qobject"] and not metadata["source_q_object"] + assert_source_link(result, local, function["id"], LOCAL.encode()) + assert not build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path).provider_records + + +@pytest.mark.parametrize("kind", ["member", "class"]) +def test_req_qml008_ac02_direct_collector_borrows_exact_callable_without_mutating_context(tmp_path, kind): + if kind == "member": + accepted, _ = utility(tmp_path, "writer.cpp") + path = tmp_path / "writer.cpp" + target = qt_metadata(occurrence(accepted, kind))["generic_target_id"] + else: + accepted = analysis(tmp_path, {"local.cpp": LOCAL + "\n"}) + path = tmp_path / "local.cpp" + target = next(node["id"] for node in accepted["nodes"] if node.get("_callable") + and node["label"] == "collect()") + nodes, edges = accepted_generic(accepted) + before = copy.deepcopy((nodes, edges)) + fresh = {"nodes": [], "edges": []} + enrich_qt_cpp([path], [fresh], root=tmp_path, accepted_nodes=nodes, accepted_edges=edges) + assert (nodes, edges) == before + assert not fresh.get("qml_failures") + assert_source_link(fresh, occurrence(fresh, kind), target, path.read_bytes()) + + +@pytest.mark.parametrize("kind", ["member", "class"]) +@pytest.mark.parametrize("corruption", ["missing", "wrong_file", "wrong_line", "ambiguous", + "not_callable", "class_like"]) +def test_req_qml008_ac02_source_fallback_rejects_unproved_or_noncallable_context(tmp_path, kind, corruption): + if kind == "member": + accepted, _ = utility(tmp_path, "writer.cpp") + path = tmp_path / "writer.cpp" + target = qt_metadata(occurrence(accepted, kind))["generic_target_id"] + else: + accepted = analysis(tmp_path, {"local.cpp": LOCAL + "\n"}) + path = tmp_path / "local.cpp" + target = next(node["id"] for node in accepted["nodes"] if node.get("_callable") + and node["label"] == "collect()") + nodes, edges = accepted_generic(accepted) + chosen = next(node for node in nodes if node["id"] == target) + if corruption == "missing": + nodes.remove(chosen) + elif corruption == "wrong_file": + chosen["source_file"] = "foreign.cpp" + elif corruption == "wrong_line": + chosen["source_location"] = "L100" + elif corruption == "ambiguous": + nodes.append({**copy.deepcopy(chosen), "id": target + "_different"}) + elif corruption == "not_callable": + chosen["_callable"] = False + elif corruption == "class_like": + chosen["_callable_class"] = True + before = copy.deepcopy((nodes, edges)) + fresh = {"nodes": [], "edges": []} + enrich_qt_cpp([path], [fresh], root=tmp_path, accepted_nodes=nodes, accepted_edges=edges) + assert (nodes, edges) == before + assert not fresh.get("qml_failures") + site = occurrence(fresh, kind) + metadata = qt_metadata(site) + assert metadata["owner_id"] == "" and metadata["class_id"] == "" + if kind == "member" and corruption == "class_like": + assert metadata["status"] == "resolved" and metadata["generic_target_id"] == target + assert not [edge for edge in fresh["edges"] if edge["target"] == site["id"] + and edge["context"] == "qt_source_site"] diff --git a/tests/test_qt_metadata_admission.py b/tests/test_qt_metadata_admission.py new file mode 100644 index 0000000000..bd46f88510 --- /dev/null +++ b/tests/test_qt_metadata_admission.py @@ -0,0 +1,87 @@ +"""Qt metadata reaches deterministic readers under existing discovery policy.""" +from __future__ import annotations + +from pathlib import Path +from typing import Callable, cast + +import pytest + +from graphify.detect import FileType, classify_file, detect +from graphify.extractors import LANGUAGE_EXTRACTORS +from graphify.extractors.qml_facts import qml_metadata + + +CASES = [ + ("CMakeLists.txt", "cmake", "qt_add_qml_module(tools URI Public.Tools VERSION 1.0 QML_FILES Main.qml)\n", "qt_module"), + ("module.cmake", "cmake", "qt_add_qml_module(tools URI Public.Tools VERSION 1.0)\n", "qt_module"), + ("tools.pro", "qmake", "QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_MAJOR_VERSION = 1\n", "qt_module"), + ("tools.pri", "qmake", "QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_MAJOR_VERSION = 1\n", "qt_module"), + ("resources.qrc", "qrc", 'Main.qml', "resource_alias"), + ("plugins.qmltypes", "qmltypes", 'import QtQuick.tooling 1.2\nModule { Component { name: "Service"; exports: ["Public.Tools/Service 1.0"] } }', "qmltypes_export"), +] + + +@pytest.mark.parametrize("name,key,source,kind", CASES, ids=[case[0] for case in CASES]) +def test_qml009_metadata_classification_registry_and_original_source_facts(tmp_path, name, key, source, kind): + path = tmp_path / name + original = b"\xef\xbb\xbf" + source.replace("\n", "\r\n").encode("utf-8") + path.write_bytes(original) + assert classify_file(path) == FileType.CODE + discovered = detect(tmp_path) + assert str(path) in discovered["files"]["code"] + result = cast(Callable[..., dict], LANGUAGE_EXTRACTORS[key])(path, root=tmp_path) + assert not result.get("error") and not result.get("qml_failures"), result + facts = [node for node in result["nodes"] if qml_metadata(node).get("kind") == kind] + assert facts + assert all(node["source_file"] == name for node in facts) + for fact in facts: + span = qml_metadata(fact)["span"] + # qmake combines source lines into a module record, including the first + # line's BOM; syntax-backed CMake/qmltypes records start after it. + assert 0 <= span["start_byte"] < span["end_byte"] <= len(original) + assert original[span["start_byte"]:span["end_byte"]].decode("utf-8") + + +def test_cmake_exact_name_does_not_reclassify_arbitrary_text(): + assert classify_file(Path("CMakeLists.txt")) == FileType.CODE + for name in ("cmakelists.txt", "CMakeLists.TXT", "CMakeLists.txt.bak", "notes.txt"): + assert classify_file(Path(name)) != FileType.CODE + assert classify_file(Path("metadata.QRC")) == FileType.CODE + assert classify_file(Path("component.UI.QML")) == FileType.CODE + + +@pytest.mark.parametrize("ignore_name", [".graphifyignore", ".gitignore"]) +def test_qt_metadata_discovery_obeys_existing_ignore_policy(tmp_path, ignore_name): + accepted = tmp_path / "CMakeLists.txt" + accepted.write_text("qt_add_qml_module(tools URI Public.Tools)\n", encoding="utf-8") + ignored = tmp_path / "ignored" + ignored.mkdir() + for name, _, source, _ in CASES: + (ignored / name).write_text(source, encoding="utf-8") + (tmp_path / ignore_name).write_text("ignored/\n", encoding="utf-8") + result = detect(tmp_path) + assert str(accepted) in result["files"]["code"] + assert not any("ignored" in Path(value).relative_to(tmp_path).parts for value in result["files"]["code"]) + + +def test_new_metadata_admission_preserves_credential_directory_exclusion(tmp_path): + credentials = tmp_path / ".ssh" + credentials.mkdir() + for name, _, source, _ in CASES: + (credentials / name).write_text(source, encoding="utf-8") + result = detect(tmp_path) + assert result["files"]["code"] == [] + + +@pytest.mark.parametrize("key,source", [ + ("cmake", "if(ENABLE_FEATURE)\nqt_add_qml_module(tools URI Public.Tools)\nendif()\n"), + ("qmake", "CONFIG(debug): QML_IMPORT_NAME = Public.Tools\n"), + ("qrc", ']>&leak;'), + ("qmltypes", "Module { Component { name: computedName() } }"), +]) +def test_registered_readers_report_unsupported_partial_inputs(tmp_path, key, source): + path = tmp_path / {"cmake": "CMakeLists.txt", "qmake": "tools.pro", "qrc": "resources.qrc", "qmltypes": "plugins.qmltypes"}[key] + path.write_text(source, encoding="utf-8") + result = cast(Callable[..., dict], LANGUAGE_EXTRACTORS[key])(path, root=tmp_path) + assert result.get("qml_failures"), result + assert result.get("partial") or result.get("error") diff --git a/tests/test_qt_metadata_boundaries.py b/tests/test_qt_metadata_boundaries.py new file mode 100644 index 0000000000..f3f405ca89 --- /dev/null +++ b/tests/test_qt_metadata_boundaries.py @@ -0,0 +1,99 @@ +"""Adversarial work bounds and syntax/read failures at actual metadata interfaces.""" +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from graphify.extractors.qml_cmake import extract_cmake, parse_cmake +from graphify.extractors.qml_project_read import MAX_PROJECT_BYTES +from graphify.extractors.qml_qmake import parse_qmake +from graphify.extractors.qml_resources import parse_qrc +from graphify.extractors.qml_types import parse_qmltypes +from graphify.qt_resource_index import normalize_url +from graphify.qml_resolution_types import Resolution + + +@pytest.mark.parametrize("source", [ + "# comment without newline", '#[[ unterminated comment', + 'qt_add_qml_module(app URI "unclosed)', 'qt_add_qml_module(app URI Public.Tools', + 'qt_add_qml_module(app URI A URI B)', 'qt_add_qml_module(app URI)', + 'qt_add_qml_module(app URI bad-name VERSION 1.0)', 'endif()', + 'function(f)\nendmacro()', 'qt_add_qml_module(app URI A QML_FILES a; b)', +]) +def test_cmake_comments_and_syntax_boundary(source): + result = parse_cmake(source) + if source == "# comment without newline": + assert not result.get("error") + else: + assert result["qml_failures"] + + +def test_cmake_scope_limit_and_source_file_growth_are_rejected(tmp_path, monkeypatch): + result = parse_cmake("if(A)\n" * 65 + "endif()\n" * 65) + assert result["diagnostics"][0]["reason"] == "cmake_scope_limit" + path = tmp_path / "CMakeLists.txt" + path.write_bytes(b"x" * (MAX_PROJECT_BYTES + 1)) + original_stat = Path.stat + # Understate only the size so the real post-read growth guard is exercised; + # native spelling admission still needs the actual mode and file identity. + def changed_stat(self, *args, **kwargs): + actual = original_stat(self, *args, **kwargs) + if self == path: + fields = {name: getattr(actual, name) for name in dir(actual) if name.startswith("st_")} + return SimpleNamespace(**(fields | {"st_size": 1})) + return actual + monkeypatch.setattr(Path, "stat", changed_stat) + actual, understated = original_stat(path), path.stat() + assert understated.st_size == 1 + assert all(getattr(understated, name) == getattr(actual, name) + for name in dir(actual) if name.startswith("st_") and name != "st_size") + assert path.samefile(path) + assert extract_cmake(path, root=tmp_path)["diagnostics"][0]["reason"] == "metadata_size_limit" + + +def test_fact_and_token_work_limits_reject_authoritative_partial_results(monkeypatch): + monkeypatch.setattr("graphify.extractors.qml_project_read.MAX_PROJECT_FACTS", 2) + result = parse_cmake("qt_add_qml_module(app URI Public.Tools QML_FILES Main.qml)") + assert result["diagnostics"][0]["reason"] == "metadata_fact_limit" + assert result["nodes"] == [] + result = parse_cmake("message(" + "x " * 10_001 + ")") + assert result["diagnostics"][0]["reason"] == "cmake_token_limit" + + +@pytest.mark.parametrize("source", [ + "}\n", "unix {\n", 'QML_IMPORT_NAME = "unclosed', + "QML_IMPORT_NAME += A B", "QML_IMPORT_NAME = A\nQML_IMPORT_VERSION = 1.0\nTARGET += a b", + "QML_IMPORT_NAME = A\nQML_IMPORT_VERSION = 1.0\nHEADERS += *.h", +]) +def test_qmake_unbalanced_and_malformed_state_never_creates_context(source): + assert parse_qmake(source)["qml_failures"] + + +@pytest.mark.parametrize("source", [ + '', '', + 'Main.qml', + '', 'unexpected', + 'Main.qml', +]) +def test_unsupported_xml_forms_are_explicit_partial_failures(source): + assert parse_qrc(source)["qml_failures"] + + +@pytest.mark.parametrize("source", [ + 'Module { Component { name: "A"; name: "B" } }', + 'Module { Component { name: "A"; Property { type: "int" } } }', + 'Module { Component { name: "A"; Unknown { name: "p" } } }', + 'Module { Component { name: "A"; Property { name: "p"; Parameter { name: "x" } } } }', + 'Module { Component { name: "A"; Method { name: "f"; Unknown { name: "x" } } } }', + 'Module { Component { name: "A"; Method { name: "f"; Parameter { Unknown {} } } } }', + 'Module { Unknown {} }', 'Module { function computed() {} }', + 'var value = 1; Module {}', 'Module { Component { name: "A"; exports: "A/B 1.0" } }', +]) +def test_qmltypes_structure_cannot_be_interpreted_as_executable_qml(source): + assert parse_qmltypes(source)["qml_failures"] + + +def test_invalid_url_grammar_returns_scoped_resolution_instead_of_reading(): + for url in ("", "x" * 385, "qrc:Main.qml", "https://host/Main.qml", "qrc://[invalid/", + "qrc:/%ff.qml"): + assert isinstance(normalize_url(url), Resolution) diff --git a/tests/test_qt_metadata_incremental.py b/tests/test_qt_metadata_incremental.py new file mode 100644 index 0000000000..c81fc39346 --- /dev/null +++ b/tests/test_qt_metadata_incremental.py @@ -0,0 +1,153 @@ +"""Real QML-011 provider-only updates must equal accepted-corpus clean rebuilds.""" +import json + +import pytest + +import graphify.extract as ex +from graphify.build import build_from_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.watch import _rebuild_code + + +def project(root): + files = { + "Main.qml": 'import Public.Tools 1.0\nService { property int displayed: status }\n', + "Other.qml": 'Item { property int other: 2 }\n', + "backend.h": '#include \nclass Backend : public QObject { Q_OBJECT ' + 'QML_NAMED_ELEMENT(Service) Q_PROPERTY(int status READ status) ' + 'public: int status() const { return 1; } };\n', + "loader.cpp": '#include \nvoid load() { ' + 'QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Main.qml")); }\n', + "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 ' + 'RESOURCE_PREFIX /ui QML_FILES Main.qml Other.qml SOURCES backend.h loader.cpp)\n', + "resources.qrc": 'Main.qml', + } + for name, source in files.items(): + (root / name).write_bytes(source.encode()) + return root / "CMakeLists.txt", root / "backend.h", root / "resources.qrc" + + +def normalized(graph): + nodes = {nid: (data.get("source_file"), qml_metadata(data), qt_metadata(data)) + for nid, data in graph.nodes(data=True) + if qml_metadata(data) or qt_metadata(data)} + edges = {(data.get("_src", source), data.get("_tgt", target), data.get("relation"), + data.get("context"), data.get("confidence"), + json.dumps(qml_metadata(data), sort_keys=True), json.dumps(qt_metadata(data), sort_keys=True)) + for source, target, data in graph.edges(data=True) + if qml_metadata(data) or qt_metadata(data)} + return nodes, edges + + +def published(root): + return load_node_link_graph(json.loads((root / "graphify-out/graph.json").read_text(encoding="utf-8"))) + + +def compare_clean(root): + paths = ex.collect_files(root, root=root) + fresh = ex.extract(paths, root=root, cache_root=root / "clean-cache", parallel=False) + assert not fresh.get("qml_failures") + assert normalized(published(root)) == normalized(build_from_json(fresh, root=root)) + + +def test_native_cpp_provider_only_edit_refreshes_unchanged_qml_and_warm_overlay(tmp_path, monkeypatch): + _, header, _ = project(tmp_path) + assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False) + initial = published(tmp_path) + # Native role views are read-only adapters; canonical C++ nodes are untouched. + native_reads = [(source, target, data) for source, target, data in initial.edges(data=True) + if data.get("context") == "qml_binding_read" and qt_metadata(data).get("native_endpoint")] + assert len(native_reads) == 1 + source, target, data = native_reads[0] + proof = qt_metadata(data)["native_endpoint"] + assert proof["canonical_target_id"] == data.get("_tgt", target) + assert proof["member_fact_id"] == proof["canonical_target_id"] + assert qt_metadata(initial.nodes[proof["member_fact_id"]])["raw_name"] == "status" + assert initial.nodes[proof["class_id"]]["source_file"] == "backend.h" + assert qt_metadata(initial.nodes[proof["provider_id"]])["raw_name"] == "Service" + assert any(qml_metadata(initial.nodes[item]).get("kind") == "qt_module" for item in proof["evidence"]) + assert qml_metadata(initial.nodes[data.get("_src", source)])["status"] == "resolved" + header.write_bytes(header.read_bytes().replace(b"Service", b"RenamedService")) + observed, real_extract = [], ex.extract + def observe(paths, **kwargs): + observed.extend(paths) + return real_extract(paths, **kwargs) + monkeypatch.setattr(ex, "extract", observe) + assert _rebuild_code(tmp_path, changed_paths=[header], no_cluster=True, acquire_lock=False) + assert tmp_path / "Main.qml" in observed + compare_clean(tmp_path) + warm = normalized(published(tmp_path)) + assert _rebuild_code(tmp_path, changed_paths=[header], no_cluster=True, acquire_lock=False) + assert normalized(published(tmp_path)) == warm + + +@pytest.mark.parametrize("mutation", ["uri", "delete", "rename", "duplicate", "ignore"]) +def test_build_provider_only_mutations_equal_clean_accepted_corpus(tmp_path, mutation): + build, _, _ = project(tmp_path) + assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False) + assert any(qml_metadata(node).get("kind") == "qt_module" for _, node in published(tmp_path).nodes(data=True)) + changed = [build] + if mutation == "uri": + build.write_bytes(build.read_bytes().replace(b"Public.Tools", b"Other.Tools")) + elif mutation == "delete": + build.unlink() + elif mutation == "rename": + renamed = tmp_path / "renamed.cmake" + build.rename(renamed) + changed.append(renamed) + elif mutation == "duplicate": + duplicate = tmp_path / "duplicate.cmake" + duplicate.write_bytes(build.read_bytes().replace(b"app ", b"other ")) + changed.append(duplicate) + elif mutation == "ignore": + ignore = tmp_path / ".graphifyignore" + ignore.write_bytes(b"CMakeLists.txt\n") + changed = [ignore] + assert _rebuild_code(tmp_path, changed_paths=changed, no_cluster=True, acquire_lock=False) + compare_clean(tmp_path) + + +@pytest.mark.parametrize("mutation", ["retarget", "delete", "rename", "ignore"]) +def test_resource_only_mutations_refresh_unchanged_cpp_loaders(tmp_path, mutation, monkeypatch): + _, _, qrc = project(tmp_path) + assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False) + assert any(qml_metadata(node).get("kind") == "resource_alias" for _, node in published(tmp_path).nodes(data=True)) + changed = [qrc] + if mutation == "retarget": + qrc.write_bytes(qrc.read_bytes().replace(b">Main.qml<", b">Other.qml<")) + elif mutation == "delete": + qrc.unlink() + elif mutation == "rename": + renamed = tmp_path / "renamed.qrc" + qrc.rename(renamed) + changed.append(renamed) + elif mutation == "ignore": + ignore = tmp_path / ".graphifyignore" + ignore.write_bytes(b"resources.qrc\n") + changed = [ignore] + observed, real_extract = [], ex.extract + def observe(paths, **kwargs): + observed.extend(paths) + return real_extract(paths, **kwargs) + monkeypatch.setattr(ex, "extract", observe) + assert _rebuild_code(tmp_path, changed_paths=changed, no_cluster=True, acquire_lock=False) + assert tmp_path / "loader.cpp" in observed + compare_clean(tmp_path) + + +@pytest.mark.parametrize("name,invalid", [ + ("CMakeLists.txt", b"if(WIN32)\nqt_add_qml_module(app URI Hidden VERSION 1.0)\nendif()"), + ("resources.qrc", b']>'), +]) +def test_failed_metadata_update_preserves_prior_graph_manifest_and_report(tmp_path, name, invalid): + project(tmp_path) + assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False) + out = tmp_path / "graphify-out" + prior = {p.name: p.read_bytes() for p in out.iterdir() if p.is_file() and p.name in { + "graph.json", "manifest.json", "report.md"}} + path = tmp_path / name + path.write_bytes(invalid) + assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True, acquire_lock=False, force=True) + assert {name: (out / name).read_bytes() for name in prior} == prior diff --git a/tests/test_qt_native_alias_scope.py b/tests/test_qt_native_alias_scope.py new file mode 100644 index 0000000000..85f7abd74b --- /dev/null +++ b/tests/test_qt_native_alias_scope.py @@ -0,0 +1,288 @@ +"""REQ-QML-016/008 lexical type authority through completed graph publication. + +These production regressions promote the public opt-in alias audit. Unknown +targets remain acceptable for unsupported bindings; simple proven aliases must +resolve their actual class. Fixtures never execute C++, QML or project hooks. +""" +from __future__ import annotations + +import json + +import pytest + +from graphify.affected import load_graph +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import extract +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_qml_bridge import build_qt_qml_bridge + +DECLARATIONS = """class Sender : public QObject { Q_OBJECT +signals: void changed(int value); +}; +class Receiver : public QObject { Q_OBJECT +public: void accept(int value) {} +}; +class Other : public QObject { Q_OBJECT +signals: void changed(int value); +}; +""" +ALIASES = ["using Sender = Other;", "typedef Other Sender;"] +QML = "import Demo 1.0\nThing { onChanged: (value) => {} }\n" + + +def persisted(root, source, *, qml=False): + """Synthetic bytes enter the real facade, assembly, JSON writer and loader.""" + path = root / "events.cpp" + path.write_bytes(source.encode("utf-8")) + paths = [path] + if qml: + qml_path = root / "Main.qml" + qml_path.write_bytes(QML.encode("utf-8")) + paths.append(qml_path) + result = extract(paths, root=root, cache_root=root / "cache", parallel=False, + qml_import_roots=(".",)) + assert not result.get("qml_failures") and not result.get("qt_failures") + output = root / "proof.json" + graph = build_from_json(result, root=root) + assert to_json(graph, {}, str(output), force=True) + payload = json.loads(output.read_text(encoding="utf-8")) + reloaded = load_graph(output) + assert reloaded.is_directed() + # Evidence belongs to original file bytes, not normalized source/graph labels. + for node in payload["nodes"]: + metadata = qt_metadata(node) or qml_metadata(node) + if not metadata or not metadata.get("span"): + continue + data = (root / node["source_file"]).read_bytes() + span = metadata["span"] + start, end = span["start_byte"], span["end_byte"] + assert 0 <= start <= end <= len(data) + for prefix, offset in (("start", start), ("end", end)): + preceding = data[:offset] + assert span[prefix + "_row"] == preceding.count(b"\n") + assert span[prefix + "_column"] == len(preceding.rsplit(b"\n", 1)[-1]) + return result, payload, reloaded + + +def native_facts(result, kind): + return [node for node in result["nodes"] if qt_metadata(node).get("kind") == kind] + + +def native_signal(result, class_name): + return next(node for node in native_facts(result, "member") + if qt_metadata(node).get("class_name") == class_name + and qt_metadata(node).get("raw_name") == "changed") + + +def endpoint_links(payload, site): + """Role nodes retain logical direction after default undirected assembly.""" + role_ids = {node["id"] for node in payload["nodes"] + if qt_metadata(node).get("kind") == "event_endpoint" + and qt_metadata(node).get("owner_id") == site["id"]} + return [edge for edge in payload["links"] + if edge["source"] in role_ids | {site["id"]} + and edge.get("context") in {"qt_connect_signal", "qt_signal_emit"}] + + +def assert_no_wrong_signal(result, payload, graph, site): + """Unsupported aliases may remain unresolved; wrong persisted edges may not.""" + wrong = native_signal(result, "Sender") + forbidden = {wrong["id"], qt_metadata(wrong)["generic_target_id"]} + links = endpoint_links(payload, site) + assert not [edge for edge in links if edge["target"] in forbidden], links + for edge in links: + assert graph.has_edge(edge["source"], edge["target"]) + metadata = qt_metadata(site) + assert metadata.get("signal_target_id") not in forbidden, metadata + assert metadata.get("target_id") not in forbidden, metadata + + +@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"]) +def test_req_qml016_connect_alias_cannot_select_unrelated_global_signal(tmp_path, alias): + """A local alias shadows Sender; connect must not persist global Sender's signal.""" + source = DECLARATIONS + f"""void wire(Other *original, Receiver *receiver) {{ + {alias} + Sender *sender = original; + QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept); +}} +""" + result, payload, graph = persisted(tmp_path, source) + site = native_facts(result, "connect")[0] + assert_no_wrong_signal(result, payload, graph, site) + + +@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"]) +def test_req_qml016_emission_alias_cannot_select_unrelated_global_signal(tmp_path, alias): + """An explicit alias-typed receiver supplies no authority for the global class.""" + source = DECLARATIONS + f"""void send(Other *original) {{ + {alias} + Sender *sender = original; + emit sender->changed(1); +}} +""" + result, payload, graph = persisted(tmp_path, source) + assert_no_wrong_signal(result, payload, graph, native_facts(result, "emission")[0]) + + +def registration_proof(root, alias, raw_type="Sender"): + source = DECLARATIONS + f"""void install() {{ + {alias} + qmlRegisterType<{raw_type}>("Demo", 1, 0, "Thing"); +}} +""" + result, payload, graph = persisted(root, source, qml=True) + bridge = build_qt_qml_bridge(result["nodes"], result["edges"], root=root) + provider = bridge.module_type("Demo", 1, 0, "Thing") + handlers = [node for node in payload["nodes"] if qml_metadata(node).get("kind") == "handler"] + assert len(handlers) == 1 + return result, payload, graph, bridge, provider, handlers[0] + + +@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"]) +def test_req_qml008_alias_registration_cannot_export_global_class_to_qml(tmp_path, alias): + """Completed native registration→QML handler edges must honor type authority.""" + result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, alias) + wrong = native_signal(result, "Sender") + forbidden = {wrong["id"], qt_metadata(wrong)["generic_target_id"]} + links = [edge for edge in payload["links"] if edge["source"] == handler["id"] + and edge.get("context") == "qml_signal_subscription"] + assert not [edge for edge in links if edge["target"] in forbidden], links + assert qml_metadata(handler).get("resolved_target_id") not in forbidden + wrong_class = qt_metadata(wrong)["class_id"] + assert qt_metadata(native_facts(result, "registration")[0]).get("class_id") != wrong_class + if provider.target_id: + assert bridge.provider_records[provider.target_id]["class_id"] != wrong_class + for edge in links: + assert graph.has_edge(edge["source"], edge["target"]) + + +def test_req_qml016_block_alias_does_not_change_before_and_after_native_scope(tmp_path): + """The alias applies inside its block; globally suppressing Sender is insufficient.""" + source = DECLARATIONS + """void wire(Sender *outer, Other *other, Receiver *receiver) { + QObject::connect(outer, &Sender::changed, receiver, &Receiver::accept); + { using Sender = Other; Sender *inner = other; + QObject::connect(inner, &Sender::changed, receiver, &Receiver::accept); } + QObject::connect(outer, &Sender::changed, receiver, &Receiver::accept); +} +""" + result, payload, graph = persisted(tmp_path, source) + before, inside, after = native_facts(result, "connect") + target = native_signal(result, "Sender")["id"] + assert qt_metadata(before)["signal_target_id"] == target + assert qt_metadata(after)["signal_target_id"] == target + assert {edge["target"] for edge in endpoint_links(payload, before)} == {target} + assert {edge["target"] for edge in endpoint_links(payload, after)} == {target} + assert_no_wrong_signal(result, payload, graph, inside) + + +@pytest.mark.parametrize("class_name", ["Sender", "Other"]) +def test_direct_native_type_control_preserves_roles_and_direction(tmp_path, class_name): + """Unshadowed connections/emissions retain distinct roles and logical direction.""" + source = DECLARATIONS + f"""void wire({class_name} *sender, Receiver *receiver) {{ + QObject::connect(sender, &{class_name}::changed, receiver, &Receiver::accept); + emit sender->changed(1); +}} +""" + result, payload, graph = persisted(tmp_path, source) + connection = native_facts(result, "connect")[0] + emission = native_facts(result, "emission")[0] + signal = native_signal(result, class_name)["id"] + assert qt_metadata(connection)["status"] == qt_metadata(emission)["status"] == "resolved" + for site in (connection, emission): + links = endpoint_links(payload, site) + assert len(links) == 1 and links[0]["target"] == signal + assert graph.has_edge(links[0]["source"], signal) + assert not graph.has_edge(signal, links[0]["source"]) + roles = [node for node in payload["nodes"] + if qt_metadata(node).get("kind") == "event_endpoint" + and qt_metadata(node).get("owner_id") == connection["id"]] + assert {qt_metadata(node)["role"] for node in roles} == {"signal", "receiver"} + assert len({node["id"] for node in roles}) == 2 + assert not any(edge["source"] == connection["id"] and edge.get("relation") == "calls" + for edge in payload["links"]) + + +@pytest.mark.parametrize("class_name", ["Sender", "Other"]) +def test_direct_registration_control_preserves_native_qml_endpoint(tmp_path, class_name): + """A literal unshadowed registration exports only its actual native signal.""" + result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, "", class_name) + signal = native_signal(result, class_name) + assert provider.status == "resolved" + assert provider.target_id is not None + assert bridge.provider_records[provider.target_id]["class_id"] == qt_metadata(signal)["class_id"] + target = qt_metadata(signal)["generic_target_id"] + assert qml_metadata(handler)["status"] == "resolved" + assert qml_metadata(handler)["resolved_target_id"] == target + links = [edge for edge in payload["links"] if edge["source"] == handler["id"] + and edge.get("context") == "qml_signal_subscription"] + assert len(links) == 1 and links[0]["target"] == target + assert graph.has_edge(handler["id"], target) and not graph.has_edge(target, handler["id"]) + + +@pytest.mark.parametrize("alias", [*ALIASES, "typedef Other First, Sender;"], + ids=["using", "typedef", "multiple"]) +def test_proven_alias_registration_exports_the_actual_class_and_qml_signal(tmp_path, alias): + """A literal alias requires positive canonical provider and persisted endpoint proof.""" + result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, alias) + signal = native_signal(result, "Other") + assert provider.status == "resolved" + assert provider.target_id is not None + assert bridge.provider_records[provider.target_id]["class_id"] == qt_metadata(signal)["class_id"] + target = qt_metadata(signal)["generic_target_id"] + assert qml_metadata(handler)["resolved_target_id"] == target + assert graph.has_edge(handler["id"], target) + assert any(edge["source"] == handler["id"] and edge["target"] == target + and edge.get("context") == "qml_signal_subscription" for edge in payload["links"]) + + +@pytest.mark.parametrize("alias,expected", [ + ("using Sender = Other;", "Other"), ("typedef Other Sender;", "Other"), + ("typedef Other First, Sender;", "Other"), + ("using First = Other; using Sender = First;", "Other"), + ("#if ENABLED\nusing Sender = Other;\n#endif", None), + ("using Sender = Other; using Sender = Receiver;", None), + ("using First = Second; using Second = First; using Sender = First;", None), + ("typedef Other *Sender;", None), ("using Sender = decltype(factory());", None), +]) +def test_local_alias_positive_and_rejection_profile(tmp_path, alias, expected): + """Aliases own local type evidence; uncertainty cannot select an outer global class.""" + source = DECLARATIONS + f"""void wire(Other *original, Receiver *receiver) {{ + {alias} + Sender *sender = original; + QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept); + emit sender->changed(1); +}} +""" + result, payload, graph = persisted(tmp_path, source) + connection, emission = native_facts(result, "connect")[0], native_facts(result, "emission")[0] + if expected: + target = native_signal(result, expected)["id"] + assert qt_metadata(connection)["signal_target_id"] == qt_metadata(emission)["target_id"] == target + assert qt_metadata(connection)["status"] == qt_metadata(emission)["status"] == "resolved" + else: + assert_no_wrong_signal(result, payload, graph, connection) + assert_no_wrong_signal(result, payload, graph, emission) + assert not endpoint_links(payload, connection) and not endpoint_links(payload, emission) + + +@pytest.mark.parametrize("prefix,alias,expected", [ + ("namespace App {\n" + DECLARATIONS, "", "App::Sender"), + (DECLARATIONS + "namespace App {\n", "using Sender = ::Other;", "Other"), +]) +def test_namespace_lookup_and_namespace_alias_keep_original_byte_spans(tmp_path, prefix, alias, expected): + """Namespace authority and original BOM/CRLF/Unicode bytes survive JSON publication.""" + source = "\ufeff// café ☃\n" + prefix + alias + """ +void wire(Sender *sender, Receiver *receiver) { + QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept); + emit sender->changed(1); +} +} +""" + result, payload, graph = persisted(tmp_path, source.replace("\n", "\r\n")) + target = native_signal(result, expected)["id"] + assert qt_metadata(native_facts(result, "connect")[0])["signal_target_id"] == target + assert qt_metadata(native_facts(result, "emission")[0])["target_id"] == target + assert any(edge["target"] == target and edge.get("context") == "qt_signal_emit" + and graph.has_edge(edge["source"], target) for edge in payload["links"]) diff --git a/tests/test_qt_native_alias_updates.py b/tests/test_qt_native_alias_updates.py new file mode 100644 index 0000000000..862d776f1b --- /dev/null +++ b/tests/test_qt_native_alias_updates.py @@ -0,0 +1,295 @@ +"""REQ-QML-008/016 native alias changes through real updates and consumers. + +Synthetic headers and source enter the production facade, CLI/watch writers and +directed JSON loader. Alias uncertainty cannot retain an old resolved endpoint. +""" +from __future__ import annotations + +import pytest +import networkx as nx + +from graphify.affected import affected_nodes, load_graph +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_analysis_state import QT_STATE_FILE +from graphify.watch import _rebuild_code +from tests.test_qt_cpp_upgrade_invalidation import cli +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated + + +def project(root, alias="using Sender = Types::Original;"): + """Accepted canonical classes remain in a header while alias sites change.""" + declaration = '''class NAME : public QObject { Q_OBJECT + Q_PROPERTY(int value READ value NOTIFY changed) + public: int value() { return 1; } + signals: void changed(int value); +}; +''' + header = declaration.replace("NAME", "Sender") + header += "namespace Types {\n" + declaration.replace("NAME", "Original") + header += declaration.replace("NAME", "Other") + "}\n" + header += "class Receiver : public QObject { Q_OBJECT public: void accept(int value) {} };\n" + (root / "types.hpp").write_bytes(header.encode()) + (root / "keep.py").write_text("def helper(): return 7\n\ndef retained(): return helper()\n", encoding="utf-8") + (root / "Main.qml").write_text( + "import Demo 1.0\nThing { property int displayed: value; onChanged: (value) => {} }\n", encoding="utf-8") + path = root / "events.cpp" + source = '''#include "types.hpp" +namespace Bridge { +ALIAS +void wire(Sender *sender, Receiver *receiver) { + QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept); + emit sender->changed(1); +} +void install() { qmlRegisterType("Demo", 1, 0, "Thing"); } +} +'''.replace("ALIAS", alias) + path.write_bytes(source.encode()) + return path + + +def facts(graph, kind): + return [(identity, qt_metadata(data)) for identity, data in graph.nodes(data=True) + if qt_metadata(data).get("kind") == kind] + + +def member(graph, class_name, name): + matches = [(identity, md) for identity, md in facts(graph, "member") + if md.get("class_name") == class_name and md.get("raw_name") == name] + assert len(matches) == 1 + return matches[0] + + +def link(graph, source, target, context): + matches = [data for _, candidate, data in graph.out_edges(source, data=True) + if candidate == target and data.get("context") == context] + assert len(matches) == 1 + return matches[0] + + +def assert_endpoints(root, expected): + """Qt role endpoints and QML canonical endpoints retain distinct identities.""" + graph = load_graph(root / "graphify-out/graph.json") + assert isinstance(graph, (nx.DiGraph, nx.MultiDiGraph)) + assert graph.is_directed() + signal_id, signal = member(graph, expected, "changed") + properties = [(identity, md) for identity, md in facts(graph, "property") + if md.get("class_name") == expected and md.get("raw_name") == "value"] + assert len(properties) == 1 + property_id, prop = properties[0] + assert prop["read"] == "value" and signal_id != property_id + canonical_signal = signal["generic_target_id"] + connection_id, connection = facts(graph, "connect")[0] + emission_id, emission = facts(graph, "emission")[0] + registration_id, registration = facts(graph, "registration")[0] + assert connection["status"] == emission["status"] == "resolved" + assert connection["signal_target_id"] == emission["target_id"] == signal_id + assert registration["class_id"] == signal["class_id"] + assert graph.nodes[canonical_signal]["source_file"] == "types.hpp" + signal_roles = [identity for identity, md in facts(graph, "event_endpoint") + if md.get("owner_id") == connection_id and md.get("role") == "signal"] + assert len(signal_roles) == 1 + role_id = signal_roles[0] + link(graph, role_id, signal_id, "qt_connect_signal") + link(graph, emission_id, signal_id, "qt_signal_emit") + assert not graph.has_edge(signal_id, role_id) and not graph.has_edge(signal_id, emission_id) + handlers = [(identity, qml_metadata(data)) for identity, data in graph.nodes(data=True) + if qml_metadata(data).get("kind") == "handler"] + assert len(handlers) == 1 + handler_id, handler = handlers[0] + assert handler["resolved_target_id"] == canonical_signal + link(graph, handler_id, canonical_signal, "qml_signal_subscription") + assert not graph.has_edge(canonical_signal, handler_id) + reads = [(source, target, data) for source, target, data in graph.edges(data=True) + if data.get("context") == "qml_binding_read" and qt_metadata(data).get("native_endpoint")] + assert len(reads) == 1 + proof = qt_metadata(reads[0][2])["native_endpoint"] + assert proof["class_id"] == signal["class_id"] + assert reads[0][1] != signal_id and reads[0][1] != canonical_signal + impacted = {hit.node_id for hit in affected_nodes(graph, signal_id, depth=2)} + assert {role_id, connection_id, emission_id}.issubset(impacted) + assert handler_id in {hit.node_id for hit in affected_nodes(graph, canonical_signal, depth=2)} + for identity in (connection_id, emission_id, registration_id): + assert not any(data.get("relation") == "calls" for _, _, data in graph.out_edges(identity, data=True)) + return graph, (connection_id, emission_id, registration_id, handler_id) + + +def setup(root, monkeypatch): + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["."]') + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("style", ["using", "typedef"]) +def test_alias_type_edit_and_removal_refreshes_native_qml_cold_warm_consumers( + tmp_path, monkeypatch, operation, style +): + """Changing or removing a namespace alias must replace every affected endpoint.""" + alias = ("using Sender = Types::TARGET;" if style == "using" else "typedef Types::TARGET Sender;") + source = project(tmp_path, alias.replace("TARGET", "Original")) + setup(tmp_path, monkeypatch) + cache = tmp_path / ".cold-cache" + cold, warm = clean(tmp_path, cache), clean(tmp_path, cache) + assert normalized(cold) == normalized(warm) + initial = run(tmp_path, monkeypatch, operation) + assert normalized(initial) == normalized(cold) + directed, sites = assert_endpoints(tmp_path, "Types::Original") + untouched = unrelated(directed) + source.write_bytes(source.read_bytes().replace(b"Types::Original", b"Types::Other")) + edited = run(tmp_path, monkeypatch, operation, [source]) + changed, _ = assert_endpoints(tmp_path, "Types::Other") + assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edit-cache")) + assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edit-cache")) + assert unrelated(changed) == untouched + # Removal makes the unqualified spelling denote the actual global Sender; + # old Types::Other relationships must not survive in persisted consumers. + source.write_bytes(source.read_bytes().replace(alias.replace("TARGET", "Other").encode(), b"")) + removed = run(tmp_path, monkeypatch, operation, [source]) + restored, _ = assert_endpoints(tmp_path, "Sender") + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache")) + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache")) + assert unrelated(restored) == untouched + assert all(identity in directed for identity in sites) + before = (tmp_path / "graphify-out/graph.json").read_bytes() + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed) + assert (tmp_path / "graphify-out/graph.json").read_bytes() == before + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_unprovable_alias_replaces_old_edges_without_global_name_fallback(tmp_path, monkeypatch, operation): + """A valid but dynamic alias is coverage uncertainty, not stale success.""" + source = project(tmp_path) + setup(tmp_path, monkeypatch) + run(tmp_path, monkeypatch, operation) + assert_endpoints(tmp_path, "Types::Original") + source.write_bytes(source.read_bytes().replace(b"using Sender = Types::Original;", + b"using Sender = decltype(factory());")) + updated = run(tmp_path, monkeypatch, operation, [source]) + directed = load_graph(tmp_path / "graphify-out/graph.json") + assert facts(directed, "connect")[0][1]["signal_status"] != "resolved" + assert facts(directed, "emission")[0][1]["status"] != "resolved" + assert not facts(directed, "registration")[0][1].get("class_id") + assert not [data for _, _, data in directed.edges(data=True) + if data.get("context") in {"qt_connect_signal", "qt_signal_emit", "qml_signal_subscription"}] + assert normalized(updated) == normalized(clean(tmp_path, tmp_path / ".uncertain-cache")) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_bad_native_alias_input_retains_outputs_then_recovers_and_repeats(tmp_path, monkeypatch, operation): + """The real fail-closed writer preserves graph, manifest, marker and analysis stamp.""" + source = project(tmp_path) + setup(tmp_path, monkeypatch) + initial = run(tmp_path, monkeypatch, operation) + assert_endpoints(tmp_path, "Types::Original") + original = source.read_bytes() + out = tmp_path / "graphify-out" + products = [out / name for name in ("graph.json", "manifest.json", ".graphify_root", QT_STATE_FILE)] + before = {path: path.read_bytes() for path in products} + source.write_bytes(original.replace(b"using Sender = Types::Original;", b"using Sender = ;")) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + cli(tmp_path, monkeypatch, "update", force=True) + assert rejected.value.code not in (None, 0) + else: + assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True, force=True) + assert before == {path: path.read_bytes() for path in products} + source.write_bytes(original) + repaired = run(tmp_path, monkeypatch, operation, [source]) + assert_endpoints(tmp_path, "Types::Original") + assert normalized(repaired) == normalized(initial) + # Recovery uses the existing update envelope (without first-scan inventory), + # but every persisted source node/edge above must match the accepted graph. + repaired_bytes = (out / "graph.json").read_bytes() + assert normalized(run(tmp_path, monkeypatch, operation, [source])) == normalized(initial) + assert repaired_bytes == (out / "graph.json").read_bytes() + + +@pytest.mark.parametrize("alias", ["using Sender = Types::Other;", "typedef Types::Other Sender;", + "typedef Types::Other First, Sender;", + "#if ENABLED\nusing Sender = Types::Other;\n#endif"]) +@pytest.mark.parametrize("header_name", ["types.hpp", "alias.hpp"]) +def test_accepted_header_alias_cannot_resolve_outer_global_native_or_qml_endpoint( + tmp_path, monkeypatch, alias, header_name +): + """Includes admit alias shadow evidence, even when a header has no Qt token. + + Cross-unit aliases are conservatively unavailable. Their declaration must + still block wrong global registration, connection and emission endpoints. + """ + source = project(tmp_path, alias="") + header = tmp_path / header_name + existing = header.read_bytes() if header.exists() else b"" + header.write_bytes(existing + ("namespace Bridge {\n" + alias + "\n}\n").encode()) + if header_name != "types.hpp": + source.write_bytes(b'#include "alias.hpp"\n' + source.read_bytes()) + setup(tmp_path, monkeypatch) + run(tmp_path, monkeypatch, "watch") + graph = load_graph(tmp_path / "graphify-out/graph.json") + wrong, md = member(graph, "Sender", "changed") + assert facts(graph, "registration")[0][1].get("class_id") != md["class_id"] + assert facts(graph, "connect")[0][1].get("signal_target_id") != wrong + assert facts(graph, "emission")[0][1].get("target_id") != wrong + assert not [data for _, _, data in graph.edges(data=True) + if data.get("context") in {"qt_connect_signal", "qt_signal_emit", "qml_signal_subscription"}] + + +def test_transitive_header_alias_owns_shadow_but_not_prior_source_use(tmp_path, monkeypatch): + """Accepted include chains preserve order; projection never expands the corpus.""" + source = project(tmp_path, alias="") + (tmp_path / "alias.hpp").write_text("namespace Bridge { using Sender = Types::Other; }\n", encoding="utf-8") + (tmp_path / "wrapper.hpp").write_text('#include "alias.hpp"\n', encoding="utf-8") + original = source.read_bytes() + source.write_bytes(original + b'#include "wrapper.hpp"\n') + setup(tmp_path, monkeypatch) + run(tmp_path, monkeypatch, "watch") + assert_endpoints(tmp_path, "Sender") + source.write_bytes(b'#include "wrapper.hpp"\n' + original) + updated = run(tmp_path, monkeypatch, "watch", [source]) + assert facts(updated, "registration")[0][1]["reason"] == "native_type_included_alias_unavailable" + assert not facts(updated, "emission")[0][1].get("target_id") + assert normalized(updated) == normalized(clean(tmp_path, tmp_path / ".include-cache")) + header = tmp_path / "alias.hpp" + header.write_text("// The alias has been removed.\n", encoding="utf-8") + refreshed = run(tmp_path, monkeypatch, "watch", [header]) + assert_endpoints(tmp_path, "Sender") + assert normalized(refreshed) == normalized(clean(tmp_path, tmp_path / ".header-edit-cache")) + + +@pytest.mark.parametrize("field,value", [("_origin", "foreign"), ("source_location", "L999")]) +def test_included_alias_fact_corruption_rejects_provenance_instead_of_resolving_outer_type( + tmp_path, monkeypatch, field, value +): + """A real published alias occurrence is immutable input; corrupt authority fails closed.""" + from copy import deepcopy + from graphify.extractors.qt_cpp_type_aliases import IncludedAliasShadows + from graphify.extractors.qt_cpp_syntax import read_cpp + source = project(tmp_path, alias="") + with (tmp_path / "types.hpp").open("ab") as header: + header.write(b"namespace Bridge { using Sender = Types::Other; }\n") + setup(tmp_path, monkeypatch) + graph = run(tmp_path, monkeypatch, "watch") + aliases = [deepcopy(data) for _, data in graph.nodes(data=True) if qt_metadata(data).get("kind") == "type_alias"] + assert len(aliases) == 1 + aliases[0][field] = value + with pytest.raises(ValueError, match="QT_METADATA"): + IncludedAliasShadows(read_cpp(source, tmp_path), aliases) + + +def test_fresh_alias_header_uses_borrowed_qt_context_without_mutating_it(tmp_path, monkeypatch): + """The facade publishes fresh alias facts even when only the header changes.""" + from copy import deepcopy + from graphify.extract import extract + project(tmp_path) + setup(tmp_path, monkeypatch) + graph = run(tmp_path, monkeypatch, "watch") + context = [dict(data, id=identity) for identity, data in graph.nodes(data=True) + if data.get("source_file") == "types.hpp"] + before = deepcopy(context) + header = tmp_path / "alias.hpp" + header.write_text("namespace Bridge { using Sender = Types::Other; }\n", encoding="utf-8") + result = extract([header], root=tmp_path, parallel=False, refresh_native=True, + resolution_context_nodes=context) + assert not result["qml_failures"] and not result.get("qt_failures") + assert len([node for node in result["nodes"] if qt_metadata(node).get("kind") == "type_alias"]) == 1 + assert context == before diff --git a/tests/test_qt_native_notify_updates.py b/tests/test_qt_native_notify_updates.py new file mode 100644 index 0000000000..9159e93ed8 --- /dev/null +++ b/tests/test_qt_native_notify_updates.py @@ -0,0 +1,202 @@ +"""REQ-QML-007/008/017: native notification refresh and durable recovery.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.affected import affected_nodes, load_graph +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import collect_files, extract +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qml_resolution_types import qml_metadata +from tests.qt_consumer_helpers import cli as consumer_cli +from tests.test_qt_final_incremental_parity import normalized, run, unrelated +from tests.test_qt_loader_updates import products +from tests.test_qt_native_property_notify import sources + + +def fixture(root, *, directed=False): + """Only literal declarations/providers participate; unrelated Python stays fixed.""" + files = sources(parameters="int count") + files["Main.qml"] = ('import QtQml\nimport Public.Tools 1.0\n' + 'Backend { onCountChanged: (renamed) => { count; renamed; } }\n') + files["keep.py"] = 'def helper(): return 7\n\ndef retained(): return helper()\n' + for name, source in files.items(): + (root / name).write_bytes(source.encode()) + if directed: + output = root / "graphify-out" + output.mkdir() + # Seed through real analysis/export; update inherits this accepted direction. + assert to_json(cold(root, root / ".seed", directed=True), {}, str(output / "graph.json"), force=True) + return root / "backend.h" + + +def cold(root, cache, *, directed=False): + """A separate real facade/build/export baseline checks every public fact.""" + result = extract(collect_files(root, root=root), root=root, cache_root=cache, parallel=False) + assert not result["failed_sources"] and not result["qml_failures"] + graph = build_from_json(result, root=root, directed=directed) + output = root / "graphify-out" + output.mkdir(exist_ok=True) + path = output / "notify-comparison.json" + assert to_json(graph, {}, str(path), force=True) + return load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + + +def subscription(graph, *, expected: str | None = "updated"): + """A handler owns a reference to the actual signal, never delivery/call inference.""" + handlers = {identity: qml_metadata(data) for identity, data in graph.nodes(data=True) + if qml_metadata(data).get("kind") == "handler"} + edges = [(data.get("_src", a), data.get("_tgt", b), data) for a, b, data in graph.edges(data=True) + if data.get("context") == "qml_signal_subscription"] + assert len(handlers) == 1 + if expected is None: + assert not edges and all(md["status"] != "resolved" and not md["resolved_target_id"] for md in handlers.values()) + return handlers, edges + assert len(edges) == 1 + source, target, edge = edges[0] + assert source in handlers and handlers[source]["resolved_target_id"] == target + assert handlers[source]["status"] == "resolved" and edge["relation"] == "references" + assert graph.nodes[target]["label"].lstrip(".") == expected + "()" + proof = qt_metadata(edge)["native_endpoint"] + assert proof["kind"] == "signal" and proof["canonical_target_id"] == target + assert graph.nodes[target]["source_file"] == "backend.h" + assert graph.has_edge(source, target) + if graph.is_directed(): + assert not graph.has_edge(target, source) + assert not any(data.get("relation") == "calls" for a, b, data in graph.edges(data=True) + if data.get("_src", a) == source) + return handlers, edges + + +@pytest.mark.parametrize("mode", ["manual", "watch"]) +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml017_ac04_notify_edits_remove_stale_edges_with_full_parity(tmp_path, monkeypatch, capsys, mode, directed): + """C++/QML/provider-only edits, removal and repeated refresh preserve source facts.""" + header = fixture(tmp_path, directed=directed) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, mode) + assert initial.is_directed() is directed + assert normalized(initial) == normalized(cold(tmp_path, tmp_path / ".cold", directed=directed)) + assert normalized(initial) == normalized(cold(tmp_path, tmp_path / ".cold", directed=directed)) + original_handlers, old_links = subscription(initial) + old_target, untouched = old_links[0][1], unrelated(initial) + valid = header.read_bytes() + header.write_bytes(valid.replace(b"updated", b"revised")) + renamed = run(tmp_path, monkeypatch, mode, [header]) + handlers, edges = subscription(renamed, expected="revised") + assert set(handlers) == set(original_handlers) and old_target not in renamed + assert normalized(renamed) == normalized(cold(tmp_path, tmp_path / ".renamed", directed=directed)) + output = tmp_path / "graphify-out/graph.json" + source, target, _ = edges[0] + assert "Main.qml" in consumer_cli(monkeypatch, capsys, output, "explain", source) + assert "revised" in consumer_cli(monkeypatch, capsys, output, "query", source) + assert source in {hit.node_id for hit in affected_nodes(load_graph(output), target, relations=["references"], depth=1)} + header.write_bytes(valid) + assert normalized(run(tmp_path, monkeypatch, mode, [header])) == normalized(initial) + + qml = tmp_path / "Main.qml" + original_qml = qml.read_bytes() + qml.write_bytes(original_qml.replace(b"onCountChanged", b"onUnknownChanged")) + changed = run(tmp_path, monkeypatch, mode, [qml]) + subscription(changed, expected=None) + assert normalized(changed) == normalized(cold(tmp_path, tmp_path / ".qml", directed=directed)) + qml.write_bytes(original_qml.replace(b"(renamed) => { count; renamed; }", b"{ count; }")) + injected = run(tmp_path, monkeypatch, mode, [qml]) + subscription(injected) + reads = [qml_metadata(data) for _, data in injected.nodes(data=True) + if qml_metadata(data).get("kind") == "read" and qml_metadata(data).get("reference") == "count"] + assert len(reads) == 1 and reads[0]["status"] == "dynamic" and reads[0]["reason"] == "javascript_lexical_binding" + assert normalized(injected) == normalized(cold(tmp_path, tmp_path / ".legacy", directed=directed)) + qml.write_bytes(original_qml) + subscription(run(tmp_path, monkeypatch, mode, [qml])) + + provider = tmp_path / "tools.pro" + original_provider = provider.read_bytes() + provider.write_bytes(original_provider.replace(b"Public.Tools", b"Public.Other")) + missing = run(tmp_path, monkeypatch, mode, [provider]) + subscription(missing, expected=None) + assert normalized(missing) == normalized(cold(tmp_path, tmp_path / ".provider", directed=directed)) + assert header.read_bytes() == valid + provider.write_bytes(original_provider) + subscription(run(tmp_path, monkeypatch, mode, [provider])) + + header.write_bytes(valid.replace(b"NOTIFY updated", b"")) + removed = run(tmp_path, monkeypatch, mode, [header]) + subscription(removed, expected=None) + assert normalized(removed) == normalized(cold(tmp_path, tmp_path / ".removed", directed=directed)) + assert unrelated(removed) == untouched + durable = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(removed) + assert products(tmp_path) == durable + + +@pytest.mark.parametrize("mode", ["manual", "force", "watch"]) +@pytest.mark.parametrize("input_file", ["backend.h", "Main.qml"]) +def test_req_qml017_ac04_notify_parse_failure_retains_products_and_recovers(tmp_path, monkeypatch, mode, input_file): + """Actual malformed native/QML input preserves all four accepted durable products.""" + fixture(tmp_path) + monkeypatch.chdir(tmp_path) + normal = "manual" if mode == "force" else mode + initial = run(tmp_path, monkeypatch, normal) + subscription(initial) + path = tmp_path / input_file + original, durable = path.read_bytes(), products(tmp_path) + path.write_bytes(original + (b"\r\nvoid broken({" if input_file.endswith(".h") else b"\r\nQtObject {")) + if mode == "watch": + from graphify.watch import _rebuild_code + assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True) + else: + with pytest.raises(SystemExit) as failure: + if mode == "force": + from tests.test_qt_cpp_upgrade_invalidation import cli + cli(tmp_path, monkeypatch, "update", force=True) + else: + run(tmp_path, monkeypatch, normal, [path]) + assert failure.value.code == 1 + assert products(tmp_path) == durable + path.write_bytes(original) + assert normalized(run(tmp_path, monkeypatch, normal, [path])) == normalized(initial) + repaired = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, normal, [])) == normalized(initial) + assert products(tmp_path) == repaired + + +@pytest.mark.parametrize("mode", ["manual", "watch"]) +def test_req_qml017_ac04_notify_manifest_failure_rolls_back_and_retries(tmp_path, monkeypatch, mode): + """A real post-graph manifest replacement failure rolls the entire cohort back.""" + import graphify.watch as watch + header = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, mode) + subscription(initial) + durable = products(tmp_path) + header.write_bytes(header.read_bytes().replace(b"updated", b"revised")) + replace, attempts = watch.os_replace_with_fallback, [] + + def fail_once(start, destination): + if str(destination).endswith("manifest.json") and not attempts: + attempts.append(str(destination)) + raise PermissionError("injected native-notify manifest replacement failure") + return replace(start, destination) + + with monkeypatch.context() as failure: + failure.setattr(watch, "os_replace_with_fallback", fail_once) + if mode == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, failure, mode, [header]) + assert rejected.value.code == 1 + else: + assert not watch._rebuild_code(tmp_path, changed_paths=[header], no_cluster=True) + assert attempts and products(tmp_path) == durable + recovered = run(tmp_path, monkeypatch, mode, [header]) + subscription(recovered, expected="revised") + assert normalized(recovered) != normalized(initial) + assert normalized(recovered) == normalized(cold(tmp_path, tmp_path / ".retry")) + repaired = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(recovered) + assert products(tmp_path) == repaired diff --git a/tests/test_qt_native_project_integration.py b/tests/test_qt_native_project_integration.py new file mode 100644 index 0000000000..decdc559b7 --- /dev/null +++ b/tests/test_qt_native_project_integration.py @@ -0,0 +1,66 @@ +"""Production build metadata supplies exact native QML exposure context.""" +from __future__ import annotations + +import pytest + +from graphify.build import build_from_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_analysis_helpers import analysis + + +HEADER = '''#include +class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service) + Q_PROPERTY(int value READ value) +public: int value() const { return 1; } + Q_INVOKABLE int fetch() { return 2; } +};''' +QML = '''import Public.Tools 1.0 as Tools +Tools.Service { property int displayed: value; property int called: fetch() } +''' + + +def native_edges(result): + return [edge for edge in result["edges"] if qt_metadata(edge).get("native_endpoint")] + + +@pytest.mark.parametrize("build_name,build", [ + ("CMakeLists.txt", "qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h)"), + ("app.pro", "CONFIG += qmltypes\nQML_IMPORT_NAME = Public.Tools\nQML_IMPORT_MAJOR_VERSION = 1\nHEADERS += backend.h\nDISTFILES += Main.qml"), +]) +def test_actual_macro_provider_uses_cmake_or_qmake_module_and_exact_cpp_endpoints(tmp_path, build_name, build): + result = analysis(tmp_path, {build_name: build, "backend.h": HEADER, "Main.qml": QML}) + graph = build_from_json(result, root=tmp_path, directed=True) + edges = native_edges(result) + calls = [edge for edge in edges if edge["relation"] == "calls"] + assert len(calls) == 1 + proof = qt_metadata(calls[0])["native_endpoint"] + assert proof["canonical_target_id"] == calls[0]["target"] + assert graph.nodes[proof["canonical_target_id"]]["label"].endswith(".fetch()") + assert graph.nodes[proof["canonical_target_id"]]["source_file"] == "backend.h" + assert graph.nodes[proof["class_id"]]["source_file"] == "backend.h" + module_ids = [item for item in proof["evidence"] if qml_metadata(graph.nodes[item]).get("kind") == "qt_module"] + assert len(module_ids) == 1 + module = qml_metadata(graph.nodes[module_ids[0]]) + assert (module["uri"], module["major"], module["minor"]) == ("Public.Tools", 1, 0) + assert graph.nodes[module_ids[0]]["source_file"] == build_name + assert any(edge["context"] == "qml_binding_read" for edge in edges) + assert all(graph.has_edge(edge["source"], edge["target"]) for edge in edges) + + +def test_same_directory_without_literal_build_source_membership_does_not_expose_class(tmp_path): + result = analysis(tmp_path, {"CMakeLists.txt": "qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml)", + "backend.h": HEADER, "Main.qml": QML}) + assert not native_edges(result) + uses = [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == "type_use"] + assert uses and all(md["status"] != "resolved" for md in uses) + assert any(qt_metadata(node).get("kind") == "registration" for node in result["nodes"]) + + +def test_cpp_source_in_two_distinct_build_contexts_has_no_arbitrary_native_provider(tmp_path): + module = "qt_add_qml_module({target} URI Public.Tools VERSION 1.0 SOURCES backend.h)" + result = analysis(tmp_path, {"CMakeLists.txt": module.format(target="one"), + "duplicate.cmake": module.format(target="two"), "backend.h": HEADER, "Main.qml": QML}) + assert not native_edges(result) + imports = [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == "import_resolution"] + assert imports and all(md["status"] == "ambiguous" for md in imports) diff --git a/tests/test_qt_native_property_notify.py b/tests/test_qt_native_property_notify.py new file mode 100644 index 0000000000..61efc3e7c4 --- /dev/null +++ b/tests/test_qt_native_property_notify.py @@ -0,0 +1,233 @@ +"""REQ-QML-007/008/017: native property handlers use the actual NOTIFY signal.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qml_resolution_types import qml_metadata +from tests.qt_analysis_helpers import analysis + + +def sources(*, notify="updated", parameters="", attribute="", handlers="normal", signal_role="signals", extra="", module=True): + """Qmake owns registration; source declarations own property/accessor/signal proof.""" + clause = "NOTIFY " + notify if notify else "" + header = f'''// Original bytes: café +class Backend : public QObject {{ Q_OBJECT QML_ELEMENT + Q_PROPERTY(int count READ count {clause} {attribute}) + {extra} + public: int count() const; + {signal_role}: void {notify or 'updated'}({parameters}); +}}; +''' + if handlers == "normal": + body = 'Backend { id: backend; onCountChanged: { count; } }' + elif handlers == "connections": + body = ('Backend { id: backend; property Connections observer: Connections { ' + 'target: backend; function onCountChanged() { count; } } }') + else: + body = ('Backend { id: backend; onCountChanged: {}; onTotalChanged: {} }') + files = {"backend.h": "\ufeff" + header.replace("\n", "\r\n"), + "Main.qml": 'import QtQml\nimport Public.Tools 1.0\n' + body + '\n'} + if module: + files["tools.pro"] = 'QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.0\nHEADERS += backend.h\nQML_FILES += Main.qml\n' + return files + + +def persisted(root, files, *, directed=False): + """Cross-language endpoint proof survives actual graph publication and reload.""" + result = analysis(root, files) + graph = build_from_json(result, root=root, directed=directed) + output = root / "notify.json" + assert to_json(graph, {}, str(output), force=True) + payload = json.loads(output.read_text(encoding="utf-8")) + restored = load_node_link_graph(payload) + handlers = {node["id"]: node for node in result["nodes"] if qml_metadata(node).get("kind") == "handler"} + links = [edge for edge in payload["links"] if edge["source"] in handlers and edge.get("context") == "qml_signal_subscription"] + return result, restored, handlers, links, output + + +def assert_notify(result, graph, handlers, links, expected="updated"): + """Each handler references the exact canonical signal with source-owned evidence.""" + assert handlers and len(links) == len(handlers) + native_members = {node["id"]: node for node in result["nodes"] if qt_metadata(node).get("kind") == "member"} + for edge in links: + site, target = handlers[edge["source"]], graph.nodes[edge["target"]] + md, proof = qml_metadata(site), qt_metadata(edge)["native_endpoint"] + assert md["status"] == "resolved" and md["resolved_target_id"] == edge["target"] + assert target["source_file"] == "backend.h" and target["label"].lstrip(".") == expected + "()" + assert edge["relation"] == "references" and edge["confidence"] == "INFERRED" + assert graph.has_edge(edge["source"], edge["target"]) + if graph.is_directed(): + assert not graph.has_edge(edge["target"], edge["source"]) + else: + assert (graph.edges[edge["source"], edge["target"]]["_src"], + graph.edges[edge["source"], edge["target"]]["_tgt"]) == (edge["source"], edge["target"]) + assert proof["kind"] == "signal" and proof["canonical_target_id"] == edge["target"] + member = native_members[proof["member_fact_id"]] + assert qt_metadata(member)["raw_name"] == expected and "signal" in qt_metadata(member)["roles"] + if md.get("reason") == "native_property_notify_signal": + evidence = [qt_metadata(node) for node in result["nodes"] if node["id"] in proof["evidence"]] + assert any(item.get("kind") == "property" for item in evidence) + assert any(item.get("kind") == "property_accessor" and item.get("accessor_role") == "notify" for item in evidence) + assert edge["source_file"] == "Main.qml" and edge["source_location"] == site["source_location"] + span = md["span"] + original = next(node for node in result["nodes"] if node["id"] == site["id"]) + assert original["source_location"].startswith("L") and qt_metadata(edge)["span"] == span + + +@pytest.mark.parametrize("directed", [False, True]) +@pytest.mark.parametrize("handlers", ["normal", "connections"]) +@pytest.mark.parametrize("notify", ["updated", "countChanged"]) +def test_req_qml007_ac03_native_property_handler_maps_custom_and_conventional_notify(tmp_path, directed, handlers, notify): + """QML's handler spelling is property-based even when C++ signal differs.""" + result, graph, sites, edges, _ = persisted(tmp_path, sources(notify=notify, handlers=handlers), directed=directed) + assert_notify(result, graph, sites, edges, notify) + original = (tmp_path / "backend.h").read_bytes() + prop = next(qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "property") + span = prop["span"] + assert original[span["start_byte"]:span["end_byte"]] == f"Q_PROPERTY(int count READ count NOTIFY {notify} )".encode() + signal = next(qt_metadata(node) for node in result["nodes"] + if qt_metadata(node).get("kind") == "member" and qt_metadata(node).get("raw_name") == notify) + member_span = signal["span"] + assert notify.encode() + b"(" in original[member_span["start_byte"]:member_span["end_byte"]] + + +def test_req_qml008_ac02_shared_notify_preserves_two_independent_handler_sites(tmp_path): + """Shared NOTIFY signals do not collapse the two source handler occurrences.""" + extra = 'Q_PROPERTY(int total READ total NOTIFY updated) public: int total() const;' + result, graph, sites, edges, _ = persisted(tmp_path, sources(extra=extra, handlers="shared")) + assert_notify(result, graph, sites, edges) + assert len(edges) == 2 and len({edge["source"] for edge in edges}) == 2 + assert len({edge["target"] for edge in edges}) == 1 + + +@pytest.mark.parametrize("options", [ + {"notify": ""}, {"notify": "", "attribute": "CONSTANT"}, + {"attribute": "CONSTANT"}, {"signal_role": "public"}, + {"parameters": "QString changed"}, {"attribute": "REVISION 1"}, + {"module": False}, +]) +def test_req_qml008_ac03_invalid_or_unexposed_notify_cannot_create_subscription(tmp_path, options): + """Missing notification, ordinary methods, type/version/provider gaps stay explicit.""" + _, _, sites, edges, _ = persisted(tmp_path, sources(**options)) + assert sites and not edges + assert all(qml_metadata(site)["status"] != "resolved" and not qml_metadata(site).get("resolved_target_id") for site in sites.values()) + + +@pytest.mark.parametrize("parameters", ["", "int count"]) +def test_req_qml007_ac03_notify_parameter_binding_uses_actual_signal_signature(tmp_path, parameters): + """A signal parameter shadows a same-named native property in the handler body.""" + result, graph, handlers, links, _ = persisted(tmp_path, sources(parameters=parameters)) + assert_notify(result, graph, handlers, links) + reads = [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == "read" + and qml_metadata(node).get("reference") == "count"] + assert len(reads) == 1 + assert reads[0]["status"] == ("dynamic" if parameters else "resolved") + if parameters: + assert reads[0]["reason"] == "javascript_lexical_binding" and not reads[0]["resolved_target_id"] + + +def test_req_qml017_ac04_native_notify_query_and_affected_use_canonical_signal(tmp_path, monkeypatch, capsys): + """Consumers retain the canonical endpoint and QML occurrence direction.""" + from graphify.affected import affected_nodes, load_graph + from tests.qt_consumer_helpers import cli + result, graph, sites, links, output = persisted(tmp_path, sources(), directed=True) + assert_notify(result, graph, sites, links) + source, target = links[0]["source"], links[0]["target"] + assert "Main.qml" in cli(monkeypatch, capsys, output, "explain", source) + assert "updated" in cli(monkeypatch, capsys, output, "query", source) + assert source in {hit.node_id for hit in affected_nodes(load_graph(output), target, relations=["references"], depth=1)} + + +@pytest.mark.parametrize("damage", ["overload", "duplicate_property", "private_signal", "conditional"]) +def test_req_qml008_ac03_ambiguous_private_or_conditional_native_notify_is_not_guessed(tmp_path, damage): + """A plausible spelling does not replace exact accessor/public-signal authority.""" + files = sources() + if damage == "overload": + files["backend.h"] = files["backend.h"].replace('void updated();', 'void updated(); void updated(int count);') + elif damage == "duplicate_property": + files["backend.h"] = files["backend.h"].replace('Q_PROPERTY(int count READ count NOTIFY updated )', + 'Q_PROPERTY(int count READ count NOTIFY updated ) Q_PROPERTY(int count READ count NOTIFY updated)') + elif damage == "private_signal": + files["backend.h"] = files["backend.h"].replace('signals: void updated();', 'private: Q_SIGNAL void updated();') + else: + files["backend.h"] = files["backend.h"].replace(' Q_PROPERTY', '\r\n#if FEATURE\r\n Q_PROPERTY').replace( + 'count READ count NOTIFY updated )', 'count READ count NOTIFY updated )\r\n#endif\r\n') + _, _, handlers, edges, _ = persisted(tmp_path, files) + assert handlers and not edges and all(qml_metadata(node)["status"] != "resolved" for node in handlers.values()) + + +def test_req_qml008_ac02_qml_inheritance_uses_native_notify_without_cpp_ancestry_guess(tmp_path): + """The supported source QML base chain retains its exact native provider.""" + files = sources() + files["Base.qml"] = 'import Public.Tools 1.0\nBackend {}\n' + files["Main.qml"] = 'import QtQml\nBase { onCountChanged: count }\n' + files["tools.pro"] += 'QML_FILES += Base.qml\n' + result, graph, handlers, edges, _ = persisted(tmp_path, files) + assert_notify(result, graph, handlers, edges) + + +def test_req_qml008_ac03_unproved_native_inherited_property_cannot_invent_notify(tmp_path): + """C++ ancestor lookup stays outside the current native-member bounded profile.""" + files = sources() + files["backend.h"] = files["backend.h"].replace('class Backend', 'class Base').replace('QML_ELEMENT', '') + files["backend.h"] += 'class Backend : public Base { Q_OBJECT QML_ELEMENT };\r\n' + _, _, handlers, edges, _ = persisted(tmp_path, files) + assert handlers and not edges + + +@pytest.mark.parametrize("damage", ["drop_accessor", "wrong_owner", "wrong_span", "wrong_target"]) +def test_req_qml008_ac03_notify_join_requires_accepted_accessor_identity(tmp_path, damage): + """A direct index join must reject corrupted/missing provenance, without reads.""" + import copy + from graphify.extractors.qt_cpp_facts import update_qt + from graphify.qt_project_index import QtProjectIndex + from graphify.qt_qml_bridge import build_qt_qml_bridge + result = analysis(tmp_path, sources()) + borrowed = copy.deepcopy(result) + nodes = copy.deepcopy(result["nodes"]) + accessor = next(node for node in nodes if qt_metadata(node).get("kind") == "property_accessor" + and qt_metadata(node).get("accessor_role") == "notify") + if damage == "drop_accessor": + nodes.remove(accessor) + elif damage == "wrong_owner": + update_qt(accessor, owner_id="unrelated") + elif damage == "wrong_span": + update_qt(accessor, span={**qt_metadata(accessor)["span"], "start_byte": 0}) + else: + update_qt(accessor, generic_target_id="unrelated") + bridge = build_qt_qml_bridge(nodes, result["edges"], root=tmp_path, + project_index=QtProjectIndex(nodes, result["edges"], root=tmp_path)) + provider = next(iter(bridge.provider_records)) + property_id = next(node["id"] for node in nodes if qt_metadata(node).get("kind") == "property") + assert bridge.property_notify(property_id, provider).status != "resolved" + assert result == borrowed + + +@pytest.mark.parametrize("style", ["legacy", "arrow", "anonymous", "connections"]) +def test_req_qml007_ac03_notify_binds_only_block_injection_or_explicit_formals(tmp_path, style): + """Signal parameter names do not shadow a function's outer property dependency.""" + files = sources(parameters="int count") + if style == "legacy": + handler = 'onCountChanged: { count; }' + elif style == "arrow": + handler = 'onCountChanged: (renamed) => { count; renamed; }' + elif style == "anonymous": + handler = 'onCountChanged: function(renamed) { count; renamed; }' + else: + handler = ('property Connections observer: Connections { target: backend; ' + 'function onCountChanged(renamed) { count; renamed; } }') + files["Main.qml"] = 'import QtQml\nimport Public.Tools 1.0\nBackend { id: backend; ' + handler + ' }\n' + result, graph, handlers, edges, _ = persisted(tmp_path, files) + assert_notify(result, graph, handlers, edges) + reads = {qml_metadata(node)["reference"]: qml_metadata(node) for node in result["nodes"] + if qml_metadata(node).get("kind") == "read"} + assert reads["count"]["status"] == ("dynamic" if style == "legacy" else "resolved") + if style != "legacy": + assert reads["renamed"]["status"] == "dynamic" and reads["renamed"]["reason"] == "javascript_lexical_binding" + assert qml_metadata(next(iter(handlers.values())))["implicit_parameters"] is (style == "legacy") diff --git a/tests/test_qt_nearby_alias_fixture.py b/tests/test_qt_nearby_alias_fixture.py new file mode 100644 index 0000000000..5977e4df1c --- /dev/null +++ b/tests/test_qt_nearby_alias_fixture.py @@ -0,0 +1,59 @@ +"""REQ-QML-020: real multi-ancestor short paths retain a nearby alias scenario.""" +from __future__ import annotations + +import ctypes +import os +from pathlib import Path +import tempfile + +import pytest + +from tests.test_qt_facade_path_aliases import ( + GENERIC, assert_sources, graph, nearby_parent_alias, normalized, write_generic, +) + + +@pytest.fixture(autouse=True) +def many_short_ancestors(tmp_path, monkeypatch): + """Provide actual long TEMP ancestors before the shared alias fixture runs.""" + if os.name != "nt": + pytest.skip("Windows short-path API is not applicable on this host") + parent = tmp_path.resolve() + for index in range(5): + parent /= f"graphify long ancestor {index}" + parent.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(parent)) + + +@pytest.mark.parametrize("nearby_parent_alias", ["windows_short"], indirect=True) +def test_req_qml020_ac01_real_short_ancestors_preserve_nearby_facade_provenance( + nearby_parent_alias): + """The real fixture admits one short basename, then cold/warm facts stay equal.""" + parent, alias = nearby_parent_alias + api = ctypes.windll.kernel32.GetShortPathNameW + api.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32) + api.restype = ctypes.c_uint32 + buffer = ctypes.create_unicode_buffer(32768) + length = api(str(parent), buffer, len(buffer)) + assert 0 < length < len(buffer) + full_short = Path(buffer.value) + full_relative = Path(os.path.relpath(full_short, parent)) + if sum(part == ".." for part in full_relative.parts) <= 3: + pytest.skip("Filesystem does not expose multiple ancestor short-path aliases") + assert full_short.resolve() == parent and alias.resolve() == parent + nearby_relative = Path(os.path.relpath(alias, parent)) + assert sum(part == ".." for part in nearby_relative.parts) == 1 + assert alias.parent == parent.parent and alias.name == full_short.name + + # Exercise the same production interface as the original hosted regression; + # reanchoring the fixture must not hide cold/warm source or edge corruption. + canonical, supplied = parent / "native-profile", alias / "native-profile" + canonical.mkdir() + write_generic(canonical) + expected, _ = graph(canonical, canonical, parent / "canonical-cache", GENERIC) + cold, cold_result = graph(supplied, supplied, parent / "alias-cache", GENERIC) + warm, warm_result = graph(supplied, supplied, parent / "alias-cache", GENERIC) + assert_sources(cold_result, GENERIC) + assert_sources(warm_result, GENERIC) + assert normalized(cold) == normalized(warm) == normalized(expected) + assert GENERIC == {name: (canonical / name).read_bytes() for name in GENERIC} diff --git a/tests/test_qt_orphan_cleanup.py b/tests/test_qt_orphan_cleanup.py new file mode 100644 index 0000000000..9dbbe6e90d --- /dev/null +++ b/tests/test_qt_orphan_cleanup.py @@ -0,0 +1,94 @@ +"""REQ-QML-011-AC04/REQ-QML-017-AC04: accepted full refresh retires stale stubs.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from graphify.qt_orphan_cleanup import prune_stale_ast_orphans +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated +from tests.test_qt_reflection_updates import cpp_source, fixture + + +@pytest.mark.parametrize("operation", ["invoke", "static_read", "static_write", "property_handle"]) +def test_req_qml017_ac04_watch_restored_sdk_source_has_no_stale_generic_placeholder(tmp_path, monkeypatch, operation): + """An actual source class introduces external type stubs, then is removed.""" + cpp = fixture(tmp_path, operation) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, "watch") + valid, untouched = cpp.read_bytes(), unrelated(initial) + cpp.write_bytes(cpp_source(tmp_path, operation, "local_alias")) + shadow = run(tmp_path, monkeypatch, "watch", [cpp]) + assert "qobject" in shadow + cpp.write_bytes(valid) + restored = run(tmp_path, monkeypatch, "watch", [cpp]) + assert "qobject" not in restored, "Removed source declaration left its unreferenced generic AST stub" + assert normalized(restored) == normalized(initial) == normalized(clean(tmp_path, tmp_path / ".restored-cold")) + assert unrelated(restored) == untouched + + +def payload(): + """Prior accepted graph roles differ even when their source path is empty.""" + base = {"file_type": "code", "_origin": "ast", "source_file": "", "source_location": ""} + nodes: list[dict] = [{**base, "id": name, "label": name} for name in + ("stale", "fresh", "edge_target", "hyper_target", "typed", "source", "semantic", "callable", "metadata")] + by_id = {node["id"]: node for node in nodes} + by_id["typed"]["type"] = "class" + by_id["source"]["source_file"] = "keep.cpp" + by_id["semantic"]["_origin"] = "semantic" + by_id["callable"]["_callable"] = True + by_id["metadata"]["metadata"] = {"qt": {"contract_version": 1, "kind": "class"}} + return {"nodes": nodes, "edges": [{"source": "semantic", "target": "edge_target"}], + "hyperedges": [{"nodes": ["semantic", "hyper_target"]}], "input_tokens": 0} + + +def test_req_qml011_ac04_cleanup_preserves_authority_references_and_borrowed_dictionaries(): + """Only stale generic placeholders are retired, without in-place mutation.""" + original = payload() + borrowed = copy.deepcopy(original) + candidate = prune_stale_ast_orphans(original, fresh_ids={"fresh"}, complete_refresh=True) + assert {node["id"] for node in candidate["nodes"]} == {node["id"] for node in original["nodes"]} - {"stale"} + assert original == borrowed + assert candidate["edges"] == original["edges"] and candidate["hyperedges"] == original["hyperedges"] + + +@pytest.mark.parametrize("field", ["nodes", "members", "node_ids"]) +def test_req_qml011_ac04_hyperedge_member_aliases_protect_source_less_ast_nodes(field): + """All existing hyperedge membership spellings protect their accepted target.""" + original = payload() + original["hyperedges"] = [{field: ["stale"]}] + assert "stale" in {node["id"] for node in prune_stale_ast_orphans(original, fresh_ids=set(), complete_refresh=True)["nodes"]} + + +def test_req_qml011_ac04_partial_refresh_is_not_authority_to_remove_placeholder(): + original = payload() + assert prune_stale_ast_orphans(original, fresh_ids=set(), complete_refresh=False) is original + + +@pytest.mark.parametrize("mode", ["manual", "watch", "watch_policy"]) +def test_req_qml011_ac04_complete_refresh_keeps_semantic_connected_and_hyperedge_stub_context(tmp_path, monkeypatch, mode): + """The production reconcile path retains facts that fresh code cannot recreate.""" + cpp = fixture(tmp_path, "invoke") + monkeypatch.chdir(tmp_path) + run(tmp_path, monkeypatch, "watch") + cpp.write_bytes(cpp_source(tmp_path, "invoke", "local_alias")) + run(tmp_path, monkeypatch, "watch", [cpp]) + output = tmp_path / "graphify-out/graph.json" + previous = json.loads(output.read_text(encoding="utf-8")) + role_nodes = payload()["nodes"] + retained = [node for node in role_nodes if node["id"] in {"semantic", "edge_target", "hyper_target"}] + previous["nodes"].extend(retained) + previous["links"].append({"source": "semantic", "target": "edge_target", "relation": "references", + "confidence": "INFERRED", "_origin": "semantic", "source_file": ""}) + previous["hyperedges"] = [{"id": "retained_hyperedge", "nodes": ["semantic", "hyper_target"]}] + output.write_text(json.dumps(previous), encoding="utf-8") + cpp.write_bytes(cpp_source(tmp_path, "invoke")) + if mode == "watch_policy": + import graphify.qt_incremental as policy + monkeypatch.setattr(policy, "QT_POLICY_VERSION", policy.QT_POLICY_VERSION + 1) + restored = run(tmp_path, monkeypatch, "manual" if mode == "manual" else "watch", [cpp]) + assert "qobject" not in restored + assert {"semantic", "edge_target", "hyper_target"} <= set(restored) + assert restored.has_edge("semantic", "edge_target") + assert json.loads(output.read_text(encoding="utf-8"))["hyperedges"] == previous["hyperedges"] diff --git a/tests/test_qt_orphan_import_proof.py b/tests/test_qt_orphan_import_proof.py new file mode 100644 index 0000000000..cdb3abece5 --- /dev/null +++ b/tests/test_qt_orphan_import_proof.py @@ -0,0 +1,144 @@ +"""INC-QML-35: accepted prior import identity bounds external orphan retirement.""" +from __future__ import annotations + +import copy + +import pytest + +from graphify.build import _EXTERNAL_STUB_RELATIONS, _load_existing_graph, build_from_json +from graphify.export import to_json +from graphify.qt_orphan_cleanup import previous_import_targets, prune_stale_ast_orphans +from tests.test_qt_cpp_file_roles import cpp_fixture + + +@pytest.fixture +def prior(tmp_path): + """Actual C++ import and builder-created external role survive the normal origin-classifying reload.""" + graph = build_from_json(cpp_fixture(tmp_path), root=tmp_path) + path = tmp_path / "previous.json" + assert to_json(graph, {}, str(path)) + loaded = _load_existing_graph(path) + assert loaded is not None + nodes, edges, hyperedges, directed = loaded + payload = {"nodes": nodes, "links": edges, "hyperedges": hyperedges, "directed": directed} + assert next(node for node in nodes if node["id"] == "missing")["_origin"] == "semantic" + return payload + + +def targets(prior): + return previous_import_targets(prior, import_relations=_EXTERNAL_STUB_RELATIONS) + + +def import_edge(prior): + return next(edge for edge in prior["links"] if edge.get("relation") == "imports" and edge["target"] == "missing") + + +def removal(prior, node, *, complete=True, fresh=(), edges=(), hyperedges=()): + result = {"nodes": [node], "edges": list(edges), "hyperedges": list(hyperedges)} + before = copy.deepcopy(result) + candidate = prune_stale_ast_orphans(result, fresh_ids=set(fresh), complete_refresh=complete, + prior_import_ids=targets(prior)) + assert result == before + return candidate["nodes"] + + +def test_actual_prior_import_proof_removes_only_unused_external_role_without_mutation(prior): + """A generated endpoint needs both source-owned prior identity and absence of every live reference.""" + before = copy.deepcopy(prior) + assert targets(prior) == {"missing"} + node = next(node for node in prior["nodes"] if node["id"] == "missing") + assert not removal(prior, node) + assert prior == before + # Persisted presentation properties do not convert a generated endpoint + # into a source definition or alter its known import ownership. + assert not removal(prior, {**node, "x": 20, "y": 30, "community_name": "Imports"}) + + +@pytest.mark.parametrize("corruption", ["semantic", "unstamped", "relation_list", "relation_foreign", "context", + "confidence", "source_file", "empty_source", "foreign_owner", "foreign_target", + "owner_origin", "owner_source", "owner_role", "duplicate_owner", "duplicate_target", + "partial_source", "partial_target", "foreign_marker", "list_marker", "reverse_directed"]) +def test_corrupt_or_foreign_prior_import_has_no_cleanup_authority(prior, corruption): + """Borrowed historical facts with missing/conflicting ownership cannot retire an external node.""" + edge = import_edge(prior) + source, target = edge["source"], edge["target"] + owner = next(node for node in prior["nodes"] if node["id"] == source) + if corruption in {"semantic", "unstamped"}: + edge["_origin"] = "semantic" if corruption == "semantic" else None + elif corruption == "relation_list": + edge["relation"] = ["imports"] + elif corruption == "relation_foreign": + edge["relation"] = "references" + elif corruption == "context": + edge["context"] = "qt_context_access" + elif corruption == "confidence": + edge["confidence"] = "INFERRED" + elif corruption in {"source_file", "empty_source"}: + edge["source_file"] = "unrelated.cpp" if corruption == "source_file" else "" + elif corruption == "foreign_owner": + edge["source"] = "absent" + elif corruption == "foreign_target": + edge["target"] = "absent" + elif corruption in {"owner_origin", "owner_source", "owner_role"}: + key = {"owner_origin": "_origin", "owner_source": "source_file", "owner_role": "file_type"}[corruption] + owner[key] = "semantic" if corruption == "owner_origin" else "unrelated.cpp" if corruption == "owner_source" else "concept" + elif corruption in {"duplicate_owner", "duplicate_target"}: + identity = source if corruption == "duplicate_owner" else target + prior["nodes"].append(copy.deepcopy(next(node for node in prior["nodes"] if node["id"] == identity))) + else: + edge["_src"], edge["_tgt"] = source, target + if corruption == "partial_source": + edge.pop("_tgt") + elif corruption == "partial_target": + edge.pop("_src") + elif corruption == "foreign_marker": + edge["_src"] = "private-needle\nforged" + elif corruption == "list_marker": + edge["_tgt"] = [target] + else: + prior["directed"] = True + edge["_src"], edge["_tgt"] = target, source + before = copy.deepcopy(prior) + assert not targets(prior) + node = next(node for node in prior["nodes"] if node["id"] == "missing") + assert removal(prior, node) == [node] + assert prior == before + + +@pytest.mark.parametrize("field", ["nodes", "links"]) +@pytest.mark.parametrize("value", [None, {}]) +def test_malformed_prior_groups_do_not_supply_proof(prior, field, value): + prior[field] = value + assert not targets(prior) + + +@pytest.mark.parametrize("role", ["foreign_origin", "ast_origin", "metadata", "empty_metadata", "callable", "type", + "source", "location", "label", "identity", "external_type"]) +def test_authoritative_or_noncanonical_external_role_is_preserved(prior, role): + """A same-name source/user/unsupported role cannot be erased by a known import target.""" + node = copy.deepcopy(next(node for node in prior["nodes"] if node["id"] == "missing")) + key, value = {"foreign_origin": ("_origin", "authored"), "ast_origin": ("_origin", "ast"), + "metadata": ("metadata", {"native_endpoint": "accepted"}), "empty_metadata": ("metadata", {}), + "callable": ("_callable", False), "type": ("type", "class"), "source": ("source_file", "kept.cpp"), + "location": ("source_location", "L2"), "label": ("label", "authored label"), + "identity": ("id", "authored_missing"), "external_type": ("external", 1)}[role] + node[key] = value + assert removal(prior, node) == [node] + + +@pytest.mark.parametrize("reference", ["partial", "fresh", "ordinary", "nodes", "members", "node_ids"]) +def test_live_reference_or_incomplete_refresh_preserves_import_endpoint(prior, reference): + """Every existing hyperedge spelling, an ordinary edge and fresh identity keep the endpoint live.""" + node = next(node for node in prior["nodes"] if node["id"] == "missing") + options = {"complete": reference != "partial", "fresh": ("missing",) if reference == "fresh" else (), + "edges": ({"source": "kept", "target": "missing"},) if reference == "ordinary" else (), + "hyperedges": ({reference: ["missing"]},) if reference in {"nodes", "members", "node_ids"} else ()} + assert removal(prior, node, **options) == [node] + + +def test_complete_undirected_marker_pair_preserves_actual_prior_source_ownership(prior): + edge = import_edge(prior) + source, target = edge["source"], edge["target"] + edge["source"], edge["target"] = target, source + edge["_src"], edge["_tgt"] = source, target + assert targets(prior) == {"missing"} diff --git a/tests/test_qt_overload_consumers.py b/tests/test_qt_overload_consumers.py new file mode 100644 index 0000000000..665b1f83cc --- /dev/null +++ b/tests/test_qt_overload_consumers.py @@ -0,0 +1,100 @@ +"""INC-QML-15: distinct constructor bodies own durable native relationships.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.affected import affected_nodes +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.serve import _query_graph_text +from tests.qt_analysis_helpers import analysis, sites +from tests.test_cpp_overload_identity import constructors + + +def body(parameter="", signal="changed"): + """Literal loader handles and an emission belong to one exact constructor.""" + return (f"Backend({parameter}) {{ QQmlApplicationEngine engine; engine.load(\"qrc:/App/Main.qml\"); " + "QObject *root = engine.rootObjects().first(); root->setProperty(\"value\", 2); " + f"emit {signal}(); }}") + + +def fixture(root, *, inline=False, same_line=False, unicode=False): + """Public Qt source is analyzed as data; no engine/component or project hook executes.""" + if inline: + header = ("class Backend : public QObject { Q_OBJECT public:\n " + body() + "\n " + body("int value", "otherChanged") + + "\nsignals: void changed(); void otherChanged(); };\n") + implementation = "int unrelated(){return 7;}\n" + else: + header = ("class Backend : public QObject { Q_OBJECT public:\n Backend(); Backend(int value);\n" + "signals: void changed(); void otherChanged(); };\n") + separator = " " if same_line else "\n" + implementation = '#include "backend.hpp"\n' + body().replace("Backend(", "Backend::Backend(", 1) + implementation += separator + body("int renamed", "otherChanged").replace("Backend(", "Backend::Backend(", 1) + "\n" + if unicode: + header = ("\ufeff// caf\u00e9 \u96ea\n" + header).replace("\n", "\r\n") + implementation = ("\ufeff// caf\u00e9 \u96ea\n" + implementation).replace("\n", "\r\n") + return analysis(root, {"backend.hpp": header, "backend.cpp": implementation, + "Main.qml": "import QtQml\nQtObject { property int value: 1 }", + "app.qrc": 'Main.qml'}) + + +@pytest.mark.parametrize("inline,same_line,unicode", [(False, False, False), (False, True, False), + (True, False, False), (False, False, True), (True, False, True)]) +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml008_ac02_and_qml011_ac01_exact_overload_native_consumers(tmp_path, inline, same_line, unicode, directed): + """Canonical constructor IDs survive assembly/export/reload with exact body-owned native sites.""" + result = fixture(tmp_path, inline=inline, same_line=same_line, unicode=unicode) + found = constructors(result) + assert len(found) == 2 and len({node["id"] for node in found}) == 2 + owned = sites(result, "emission") + sites(result, "qml_access") + sites(result, "qml_load") + sites(result, "qml_root") + assert len(owned) == 8 + assert all(qt_metadata(node)["status"] == "resolved" for node in owned) + assert {qt_metadata(node)["owner_id"] for node in owned} == {node["id"] for node in found} + for node in found: + scope = node["metadata"]["cpp_constructor"] + filename = node.get("definition_file") or node["source_file"] + span = scope.get("definition_span") or scope["span"] + for site in owned: + md = qt_metadata(site) + if md["owner_id"] != node["id"]: + continue + assert site["source_file"] == filename + assert span["start_byte"] <= md["span"]["start_byte"] < md["span"]["end_byte"] <= span["end_byte"] + if md["kind"] == "emission": + assert md["target_id"] + graph = build_from_json(result, root=tmp_path, directed=directed) + output = tmp_path / "graph.json" + assert to_json(graph, {}, str(output)) + reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + for site in sites(result, "emission"): + md = qt_metadata(site) + assert reloaded.has_edge(md["owner_id"], site["id"]) + assert md["owner_id"] in {hit.node_id for hit in affected_nodes(reloaded, md["target_id"], depth=3)} + assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(site["id"], data=True)) + answer = _query_graph_text(reloaded, "Backend changed otherChanged", token_budget=8000) + assert "changed" in answer and "otherChanged" in answer and "backend.hpp" in answer + + +def test_req_qml008_ac03_unknown_overload_signature_cannot_borrow_known_native_class(tmp_path): + """Unsupported alias constructor facts remain source-visible without guessed native endpoints.""" + result = analysis(tmp_path, { + "backend.hpp": "class Backend: public QObject { Q_OBJECT public: Backend(int); Backend(Alias); signals: void changed(); };", + "backend.cpp": '#include "backend.hpp"\nBackend::Backend(Alias value) { emit changed(); }', + }) + emission = qt_metadata(sites(result, "emission")[0]) + assert emission["status"] != "resolved" and emission["target_id"] == "" + assert constructors(result) + + +def test_req_qml008_ac03_duplicate_inline_signature_cannot_authorize_emission(tmp_path): + """Contradictory source bodies do not become one accepted inline constructor owner.""" + result = analysis(tmp_path, {"backend.hpp": "class Backend: public QObject { Q_OBJECT public: " + "Backend(int value) { emit changed(); } Backend(const int other) { emit changed(); } " + "signals: void changed(); };"}) + emissions = sites(result, "emission") + assert len(emissions) == 2 + assert all(qt_metadata(node)["status"] != "resolved" and qt_metadata(node)["target_id"] == "" for node in emissions) diff --git a/tests/test_qt_pipeline_failure_context.py b/tests/test_qt_pipeline_failure_context.py new file mode 100644 index 0000000000..86830b330e --- /dev/null +++ b/tests/test_qt_pipeline_failure_context.py @@ -0,0 +1,190 @@ +"""REQ-QML-012/020: join failures remain bounded after input identity breaks.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import graphify.extract as extraction +import graphify.qt_project_membership as membership +from graphify.extractors.qml_facts import qml_metadata +from graphify.qt_qml_pipeline import resolve_qt_qml +from graphify.watch import _rebuild_code +from tests.qml_installed_smoke import native_module_smoke +from tests.test_qt_final_incremental_parity import normalized, run +from tests.test_qt_project_membership_aliases import aliased_parent # noqa: F401 - shared real host fixture + + +NAMES = ("backend.h", "Main.qml", "loader.cpp", "CMakeLists.txt", "resources.qrc") +PRIVATE_BODY = "private-filesystem-backend-body" + + +def raw_project(root, *, metadata_path=None): + """Produce actual parser facts before the owning join creates its per-run index.""" + paths = [metadata_path if name == "CMakeLists.txt" and metadata_path is not None + else root / name for name in (*NAMES, "keep.py")] + per_file = [extraction._safe_extract_with_xaml_root( + extraction._get_extractor(path), path, root) for path in paths] + assert all(not result.get("error") for result in per_file) + nodes = [node for result in per_file for node in result["nodes"]] + edges = [edge for result in per_file for edge in result["edges"]] + return paths, per_file, nodes, edges + + +def project(root): + native_module_smoke(root) + (root / "keep.py").write_bytes(b"def retained(): return 7\n") + + +def arm_failure(monkeypatch, build, error_type): + """Fail only the accepted input after real parsing, collection, and indexing.""" + real_membership, real_resolve = membership.resolve_project_memberships, Path.resolve + reached = [] + + def fail_input(path, *args, **kwargs): + if path == build: + raise error_type(PRIVATE_BODY) + return real_resolve(path, *args, **kwargs) + + def arm(results, *args, **kwargs): + assert build in results and kwargs["project_index"].nodes + assert any(qml_metadata(node).get("kind") == "qt_source" + for node in results[build]["nodes"]) + reached.append("accepted_join") + if error_type is None: + raise ValueError(PRIVATE_BODY) + # The patch survives the membership exception, so the real diagnostic + # path must withstand the same persistent OSError or alias-loop failure. + monkeypatch.setattr(Path, "resolve", fail_input) + return real_membership(results, *args, **kwargs) + + monkeypatch.setattr(membership, "resolve_project_memberships", arm) + return reached + + +def assert_failure_context(paths, per_file, failed, expected_build): + """Every affected source has bounded context; unrelated Python stays unaffected.""" + assert set(failed) == {str(path) for path in paths if path.name != "keep.py"} + for path, result in zip(paths, per_file): + if path.name == "keep.py": + assert not result.get("qml_failures") and not result.get("diagnostics") + continue + source = expected_build if path.name == "CMakeLists.txt" else path.name + assert result["qml_failures"] == [{"code": "QML_RESOLUTION_FAILED", "source_file": source}] + assert result["diagnostics"] == [{ + "code": "QML_RESOLUTION_FAILED", "severity": "error", "owner": "qt_qml_resolution", + "source_file": source, "message": "Qt/QML project join failed", + "recovery": "Correct the join failure and retry; prior graph is preserved.", + }] + assert PRIVATE_BODY not in json.dumps(per_file) + + +@pytest.mark.parametrize("error_type", [None, OSError, RuntimeError]) +def test_req_qml012_ac01_all_sources_keep_safe_context_after_join_failure( + tmp_path, monkeypatch, error_type): + """Ordinary and persistent failures return complete diagnostics, never backend exceptions.""" + root = tmp_path / "native-profile" + project(root) + paths, per_file, nodes, edges = raw_project(root) + reached = arm_failure(monkeypatch, root / "CMakeLists.txt", error_type) + failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root) + assert reached == ["accepted_join"] + assert_failure_context(paths, per_file, failed, "CMakeLists.txt") + + +@pytest.mark.parametrize("error_type", [None, OSError, RuntimeError]) +def test_req_qml012_ac01_real_alias_uses_canonical_or_explicit_unavailable_context( + aliased_parent, monkeypatch, error_type): + """Real parent aliases retain canonical context, or an empty label when identity is inaccessible.""" + parent, alias = aliased_parent + root = parent / "native-profile" + project(root) + build = alias / "native-profile/CMakeLists.txt" + assert build.resolve() == root / "CMakeLists.txt" + paths, per_file, nodes, edges = raw_project(root, metadata_path=build) + reached = arm_failure(monkeypatch, build, error_type) + failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root) + assert reached == ["accepted_join"] + assert_failure_context(paths, per_file, failed, "CMakeLists.txt" if error_type is None else "") + assert all(not qml_metadata(node).get("kind") == "membership_resolution" for node in nodes) + + +def test_req_qml020_ac02_foreign_transport_cannot_gain_authority_from_diagnostic_context(tmp_path): + """A rejected outside-root owner has no private path label and publishes no fallback target.""" + root = tmp_path / "native-profile" + project(root) + paths, per_file, nodes, edges = raw_project(root) + paths[NAMES.index("CMakeLists.txt")] = tmp_path / "private-external-input/CMakeLists.txt" + failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root) + assert_failure_context(paths, per_file, failed, "") + assert "private-external-input" not in json.dumps(per_file) + assert not any(qml_metadata(node).get("kind") == "membership_resolution" for node in nodes) + assert not any(edge.get("context") in {"qt_project_source", "qt_resource_membership"} for edge in edges) + + +@pytest.mark.parametrize("delimiter", ["\n", "\r", "\t", "\x00", "\x1b", "\x7f", "\x85", "\u2028", "\u2029"]) +def test_req_qml012_ac01_injected_context_delimiters_are_explicitly_unavailable(tmp_path, delimiter): + """Untrusted transported labels cannot inject control characters into join diagnostics.""" + root = tmp_path / "native-profile" + project(root) + paths, per_file, nodes, edges = raw_project(root) + paths[NAMES.index("CMakeLists.txt")] = root / ("injected" + delimiter + "label/CMakeLists.txt") + failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root) + assert_failure_context(paths, per_file, failed, "") + assert delimiter not in "".join(diagnostic["source_file"] for result in per_file + for diagnostic in result.get("diagnostics", [])) + + +@pytest.mark.parametrize("length", [160, 161]) +def test_req_qml012_ac01_context_ceiling_preserves_or_rejects_complete_relative_label(tmp_path, length): + """The diagnostic-only 160-character ceiling never truncates a source into another identity.""" + root = tmp_path / "native-profile" + project(root) + paths, per_file, nodes, edges = raw_project(root) + relative = "a" * (length - len("/CMakeLists.txt")) + "/CMakeLists.txt" + assert len(relative) == length + paths[NAMES.index("CMakeLists.txt")] = root / relative + failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root) + assert_failure_context(paths, per_file, failed, relative if length == 160 else "") + assert not any(qml_metadata(node).get("kind") == "membership_resolution" for node in nodes) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("error_type", [OSError, RuntimeError]) +def test_req_qml012_ac02_persistent_failure_retains_products_and_repairs_without_leaking( + tmp_path, monkeypatch, capsys, operation, error_type): + """Actual update writers reject safely, keep four durable products, repair, then repeat.""" + root = tmp_path / "native-profile" + project(root) + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(root, monkeypatch, operation) + output = root / "graphify-out" + names = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root") + before = {name: (output / name).read_bytes() for name in names} + build = root / "CMakeLists.txt" + original = build.read_bytes() + build.write_bytes(original + b"\n# trigger accepted-source refresh\n") + capsys.readouterr() + with monkeypatch.context() as failure: + reached = arm_failure(failure, build, error_type) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(root, failure, operation, [build]) + assert rejected.value.code == 1 + else: + assert not _rebuild_code(root, changed_paths=[build], no_cluster=True) + assert reached == ["accepted_join"] + captured = capsys.readouterr() + assert PRIVATE_BODY not in captured.out + captured.err + # The join's per-source code is asserted above. The actual writer retains + # its separate documented publication-rejection diagnostic at this boundary. + assert "QML_GRAPH_PRESERVED" in captured.out + captured.err + assert before == {name: (output / name).read_bytes() for name in names} + build.write_bytes(original) + repaired = run(root, monkeypatch, operation, [build]) + assert normalized(repaired) == normalized(initial) + repaired_products = {name: (output / name).read_bytes() for name in names} + assert normalized(run(root, monkeypatch, operation, [])) == normalized(initial) + assert repaired_products == {name: (output / name).read_bytes() for name in names} diff --git a/tests/test_qt_product_publication.py b/tests/test_qt_product_publication.py new file mode 100644 index 0000000000..c28b518764 --- /dev/null +++ b/tests/test_qt_product_publication.py @@ -0,0 +1,253 @@ +"""INC-QML-25: real manual/watch updates publish one accepted product cohort.""" +from __future__ import annotations + +import os +from pathlib import Path +import stat +import sys + +import pytest + +import graphify.detect as detection +import graphify.qt_analysis_state as qt_state +import graphify.publication as publication +import graphify.watch as watch +from tests.test_qt_final_incremental_parity import clean, normalized, project, run + + +PRODUCTS = ("graph.json", ".graphify_root", "manifest.json", ".qt_analysis.json") + + +def snapshot(output): + """Compare durable accepted bytes, including absent first-build products.""" + return {name: (output / name).read_bytes() if (output / name).exists() else None + for name in PRODUCTS} + + +def no_scratch(output): + assert not list(output.glob(".gfy-publish-*")) + assert not (output / ".graph.tmp.json").exists() + + +def reject(root, monkeypatch, operation, source): + if operation == "manual": + with pytest.raises(SystemExit) as result: + run(root, monkeypatch, operation, [source]) + assert result.value.code == 1 + else: + assert not watch._rebuild_code(root, changed_paths=[source], no_cluster=True) + + +def accepted_project(root, monkeypatch, operation): + source = project(root) + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(root, monkeypatch, operation) + source.write_text(source.read_text(encoding="utf-8").replace("adjustment", "offset"), + encoding="utf-8", newline="") + return source, initial, root / "graphify-out" + + +@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute") +@pytest.mark.parametrize("name", PRODUCTS) +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_inc25_readonly_each_product_preserves_cohort_and_retries(tmp_path, monkeypatch, capsys, name, operation): + """Each changed output rejects before any accepted sibling advances, then recovers.""" + source, initial, output = accepted_project(tmp_path, monkeypatch, operation) + if name == ".graphify_root": + marker = output / name + marker.write_bytes(b"\xef\xbb\xbf" + marker.read_bytes() + b"\r\n") + if name == ".qt_analysis.json": + (tmp_path / "extra").mkdir() + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '[".","extra"]') + before = snapshot(output) + target = output / name + target.chmod(stat.S_IREAD) + try: + reject(tmp_path, monkeypatch, operation, source) + assert snapshot(output) == before + assert not target.stat().st_mode & stat.S_IWRITE + assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out + no_scratch(output) + finally: + target.chmod(stat.S_IWRITE) + repaired = run(tmp_path, monkeypatch, operation, [source]) + assert normalized(repaired) != normalized(initial) + # Clean extraction uses the same configured import roots, not a copied resolver. + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".clean")) + current = snapshot(output) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(repaired) + assert snapshot(output) == current + no_scratch(output) + + +@pytest.mark.parametrize("fault", ["manifest", "stamp", "replace"]) +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_inc25_preparation_and_late_replace_faults_preserve_then_retry(tmp_path, monkeypatch, capsys, fault, operation): + """Real serializers run before an injected failure; a late replacement rolls back disk.""" + source, _, output = accepted_project(tmp_path, monkeypatch, operation) + before = snapshot(output) + with monkeypatch.context() as failure: + if fault in {"manifest", "stamp"}: + module, name = ((detection, "save_manifest") if fault == "manifest" + else (qt_state, "commit_qt_analysis")) + original = getattr(module, name) + + def write_then_fail(*args, **kwargs): + original(*args, **kwargs) + raise OSError("public test preparation failure") + + failure.setattr(module, name, write_then_fail) + else: + original = watch.os_replace_with_fallback + + def fail_late(src, dst): + if dst.name == "manifest.json": + raise OSError("public test publication failure") + return original(src, dst) + + failure.setattr(watch, "os_replace_with_fallback", fail_late) + reject(tmp_path, monkeypatch, operation, source) + assert snapshot(output) == before + assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out + no_scratch(output) + repaired = run(tmp_path, monkeypatch, operation, [source]) + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".clean")) + current = snapshot(output) + run(tmp_path, monkeypatch, operation, []) + assert snapshot(output) == current + + +def test_inc25_clustered_report_failure_preserves_graph_and_sidecars(tmp_path, monkeypatch): + """Clustering/report preparation cannot publish graph before a failed report write.""" + source = project(tmp_path) + monkeypatch.chdir(tmp_path) + assert watch._rebuild_code(tmp_path) + output = tmp_path / "graphify-out" + names = (*PRODUCTS, "GRAPH_REPORT.md", ".graphify_labels.json", ".graphify_labels.json.sig") + before = {name: (output / name).read_bytes() for name in names} + source.write_text(source.read_text(encoding="utf-8").replace("adjustment", "offset"), encoding="utf-8") + original = watch.write_text_atomic + + def report_fault(path, text, **kwargs): + if path.name == "GRAPH_REPORT.md": + raise OSError("public test report failure") + return original(path, text, **kwargs) + + with monkeypatch.context() as failure: + failure.setattr(watch, "write_text_atomic", report_fault) + assert not watch._rebuild_code(tmp_path, changed_paths=[source]) + assert {name: (output / name).read_bytes() for name in names} == before + no_scratch(output) + assert watch._rebuild_code(tmp_path, changed_paths=[source]) + + +def test_inc25_successful_ast_cache_does_not_authorize_failed_products(tmp_path, monkeypatch): + """Content-keyed valid syntax may be reusable despite rejected graph publication.""" + source = tmp_path / "sample.py" + source.write_text("def initial():\n return 1\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + assert watch._rebuild_code(tmp_path, no_cluster=True) + output = tmp_path / "graphify-out" + before = snapshot(output) + cache_root = output / "cache" + caches = {path.relative_to(cache_root): path.read_bytes() for path in cache_root.rglob("*") if path.is_file()} + source.write_text("def initial():\n return 1\n\ndef added():\n return initial()\n", encoding="utf-8") + with monkeypatch.context() as failure: + failure.setattr(detection, "save_manifest", lambda *args, **kwargs: (_ for _ in ()).throw(OSError("failed manifest"))) + assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + assert snapshot(output) == before + after = {path.relative_to(cache_root): path.read_bytes() for path in cache_root.rglob("*") if path.is_file()} + assert all(after.get(key) == original for key, original in caches.items()) + assert set(after) > set(caches) + no_scratch(output) + assert watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + + +@pytest.mark.parametrize("no_cluster", [True, False]) +def test_inc25_unchanged_graph_manifest_fault_is_still_rejected(tmp_path, monkeypatch, capsys, no_cluster): + """An unchanged topology still needs committed input hashes before success.""" + source = tmp_path / "sample.py" + source.write_text("def unchanged():\n return 1\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + assert watch._rebuild_code(tmp_path, no_cluster=no_cluster) + # Legacy raw output receives reconciliation provenance on its first reload; + # establish that accepted steady-state before exercising the fast path. + assert watch._rebuild_code(tmp_path, no_cluster=no_cluster) + output = tmp_path / "graphify-out" + before = snapshot(output) + # A value-only edit changes accepted input bytes without changing declarations. + source.write_text(source.read_text(encoding="utf-8").replace("return 1", "return 2"), encoding="utf-8") + original = detection.save_manifest + + def fault(*args, **kwargs): + original(*args, **kwargs) + raise OSError("manifest preparation failed") + + with monkeypatch.context() as failure: + failure.setattr(detection, "save_manifest", fault) + assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=no_cluster) + assert snapshot(output) == before + assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out + no_scratch(output) + assert watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=no_cluster) + assert (output / "graph.json").read_bytes() == before["graph.json"] + assert (output / "manifest.json").read_bytes() != before["manifest.json"] + + +def test_inc25_first_build_failure_publishes_no_acceptance_products(tmp_path, monkeypatch): + """Failed preparation cannot bless a new graph with absent acceptance sidecars.""" + source = project(tmp_path) + monkeypatch.chdir(tmp_path) + output = tmp_path / "graphify-out" + with monkeypatch.context() as failure: + failure.setattr(detection, "save_manifest", lambda *args, **kwargs: (_ for _ in ()).throw(OSError("manifest failure"))) + assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + assert snapshot(output) == {name: None for name in PRODUCTS} + no_scratch(output) + assert watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + assert all((output / name).exists() for name in PRODUCTS) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("phase", ["output_dir", "scratch_dir"]) +def test_inc25_actual_caller_setup_failure_retains_and_redacts(tmp_path, monkeypatch, capsys, operation, phase): + """The real driver logs only safe setup diagnostics, preserving all accepted bytes.""" + source, initial, output = accepted_project(tmp_path, monkeypatch, operation) + before = snapshot(output) + capsys.readouterr() + private_marker = "synthetic-sensitive-location" + error = PermissionError(13, private_marker + "\nbody", str(tmp_path / private_marker)) + mkdir = Path.mkdir + + def failed_mkdir(path, *args, **kwargs): + # Isolate only the external transaction-directory syscall; queue/lock + # setup and the real serializer/driver continue unchanged. + if path == output and sys._getframe(1).f_code is publication.ProductPublication.__init__.__code__: + raise error + return mkdir(path, *args, **kwargs) + + def failed_scratch(*args, **kwargs): + raise error + + with monkeypatch.context() as failure: + if phase == "output_dir": + failure.setattr(Path, "mkdir", failed_mkdir) + else: + failure.setattr(publication.tempfile, "mkdtemp", failed_scratch) + reject(tmp_path, monkeypatch, operation, source) + captured = capsys.readouterr() + assert "GRAPH_PUBLICATION_FAILED" in captured.out + assert private_marker not in captured.out + captured.err + # The existing CLI progress message names its target. The new rejection + # diagnostic must expose neither that path nor the underlying error body. + diagnostic = next(line for line in captured.out.splitlines() if "Rebuild failed:" in line) + assert str(tmp_path) not in diagnostic + assert snapshot(output) == before + no_scratch(output) + repaired = run(tmp_path, monkeypatch, operation, [source]) + assert normalized(repaired) != normalized(initial) + current = snapshot(output) + run(tmp_path, monkeypatch, operation, []) + assert snapshot(output) == current + no_scratch(output) diff --git a/tests/test_qt_project_admission.py b/tests/test_qt_project_admission.py new file mode 100644 index 0000000000..6a40110865 --- /dev/null +++ b/tests/test_qt_project_admission.py @@ -0,0 +1,139 @@ +"""QML-008/009/017 public build providers join through the actual facade.""" +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qt_project_index import QtProjectIndex +from tests.qt_analysis_helpers import analysis, sites + +HEADER = '''class Backend : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(int count READ count) +public: + int count() { return 1; } + Q_INVOKABLE int next(int value) { return value; } +}; +''' +QML = '''import Public.Tools 1.0 +Backend { property int copied: count; function run() { next(1) } } +''' +QRC = 'Main.qml' +BUILDS = { + "CMakeLists.txt": 'qt_add_qml_module(tools URI Public.Tools VERSION 1.2 RESOURCE_PREFIX /ui ' + 'QML_FILES Main.qml SOURCES backend.h loader.cpp)\n', + "tools.pro": 'TARGET = tools\nQML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.2\n' + 'HEADERS += backend.h\nSOURCES += loader.cpp\nQML_FILES += Main.qml\nRESOURCES += resources.qrc\n', +} + + +def facts(result, kind, reference=None): + return [node for node in result["nodes"] if (md := qml_metadata(node)).get("kind") == kind + and (reference is None or md.get("reference") == reference)] + + +@pytest.mark.parametrize("build_file", list(BUILDS)) +def test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints(tmp_path, build_file): + """CMake and qmake provide the same URI/version and canonical C++ members.""" + result = analysis(tmp_path, {"backend.h": HEADER, "Main.qml": QML, + build_file: BUILDS[build_file], "resources.qrc": QRC}) + project = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path) + module = project.module_context("backend.h") + assert module.status == "resolved" + assert project.module_metadata(module.target_id)["uri"] == "Public.Tools" + for kind, name in (("read", "count"), ("call", "next")): + site = facts(result, kind, name)[0] + md = qml_metadata(site) + assert md["status"] == "resolved", md + target = next(node for node in result["nodes"] if node["id"] == md["resolved_target_id"]) + assert target["source_file"] == "backend.h" + outgoing = [edge for edge in result["edges"] if edge["source"] == site["id"] and edge["target"] == target["id"]] + assert len(outgoing) == 1 and qt_metadata(outgoing[0])["native_endpoint"] + if kind == "call": + assert target.get("_callable") and not qt_metadata(target) + + +@pytest.mark.parametrize("build_file", list(BUILDS)) +def test_qml009_ac03_public_metadata_qrc_load_build_export_reload(tmp_path, build_file): + """Literal resources and module loads preserve endpoints through persisted graphs.""" + source = '''void use() { QQmlApplicationEngine engine; + engine.load(QUrl("qrc:/ui/Main.qml")); } +''' + result = analysis(tmp_path, {"backend.h": HEADER, "Main.qml": QML, "loader.cpp": source, + build_file: BUILDS[build_file], "resources.qrc": QRC}) + loader = sites(result, "qml_load")[0] + assert qt_metadata(loader)["status"] == "resolved" + component = facts(result, "component")[0] + assert qt_metadata(loader)["target_id"] == component["id"] + graph = build_from_json(result, root=tmp_path) + output = tmp_path / "roundtrip.json" + assert to_json(graph, {}, str(output), force=True) + restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert restored.has_edge(loader["id"], component["id"]) + edge = restored.edges[loader["id"], component["id"]] + assert edge["context"] == "qt_cpp_qml_load" and edge["confidence"] == "INFERRED" + assert edge["source_file"] == "loader.cpp" and qt_metadata(edge)["target_id"] == component["id"] + + +def test_qml008_ac03_macro_without_membership_or_wrong_module_does_not_guess(tmp_path): + """A class name or neighboring module is insufficient provider provenance.""" + no_build = analysis(tmp_path, {"backend.h": HEADER, "Main.qml": QML}) + assert qml_metadata(facts(no_build, "read", "count")[0])["status"] != "resolved" + wrong = analysis(tmp_path, {"backend.h": HEADER, "Main.qml": QML, + "CMakeLists.txt": 'qt_add_qml_module(tools URI Other.Tools VERSION 1.2 SOURCES backend.h)\n'}) + assert qml_metadata(facts(wrong, "read", "count")[0])["status"] != "resolved" + + +def test_qml009_ac03_build_sources_do_not_expand_accepted_cpp_corpus(tmp_path): + """A disk-only header listed by CMake cannot create a native endpoint.""" + (tmp_path / "backend.h").write_text(HEADER, encoding="utf-8") + result = analysis(tmp_path, {"Main.qml": QML, "CMakeLists.txt": BUILDS["CMakeLists.txt"]}) + assert not sites(result, "class") + assert qml_metadata(facts(result, "read", "count")[0])["status"] != "resolved" + + +@pytest.mark.parametrize("build_file", list(BUILDS)) +def test_qml017_ac01_literal_module_load_reaches_declared_component_and_property(tmp_path, build_file): + """Both build profiles supply the exact module/type loader provenance.""" + source = '''void use(){ QQmlApplicationEngine engine; + engine.loadFromModule("Public.Tools", "Main"); auto root = engine.rootObjects().first(); + root->setProperty("count", 2); }''' + result = analysis(tmp_path, {"Main.qml": 'import QtQml\nQtObject { property int count: 1 }', + "loader.cpp": source, build_file: BUILDS[build_file], "resources.qrc": QRC}) + for kind in ("qml_load", "qml_root", "qml_access"): + assert qt_metadata(sites(result, kind)[0])["status"] == "resolved" + loader = qt_metadata(sites(result, "qml_load")[0]) + assert loader["target_id"] == facts(result, "component")[0]["id"] + + +def test_qml009_ac03_generated_resource_path_requires_explicit_prefix(tmp_path): + """A literal CMake prefix creates aliases; unevaluated policy defaults do not.""" + source = 'void use(){ QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Public/Tools/Main.qml")); }' + files = {"Main.qml": 'import QtQml\nQtObject {}', "loader.cpp": source, + "CMakeLists.txt": BUILDS["CMakeLists.txt"]} + result = analysis(tmp_path, files) + assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved" + files["CMakeLists.txt"] = files["CMakeLists.txt"].replace("RESOURCE_PREFIX /ui ", "") + result2 = analysis(tmp_path, files) + md = qt_metadata(sites(result2, "qml_load")[0]) + assert (md["status"], md["reason"]) == ("unavailable", "resource_alias_unavailable") + + +def test_qml017_ac01_resource_duplicate_and_unaccepted_targets_remain_unresolved(tmp_path): + """A duplicate alias and a disk-only target cannot authorize a C++ load edge.""" + source = 'void use(){ QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Main.qml")); }' + duplicate = QRC.replace('', 'Main.qml') + result = analysis(tmp_path, {"loader.cpp": source, "Main.qml": 'import QtQml\nQtObject {}', "resources.qrc": duplicate}) + md = qt_metadata(sites(result, "qml_load")[0]) + assert (md["status"], md["reason"]) == ("ambiguous", "duplicate_resource_alias") + hidden = tmp_path / "Hidden.qml" + hidden.write_text('import QtQml\nQtObject {}', encoding="utf-8") + result2 = analysis(tmp_path, {"loader.cpp": source, + "resources.qrc": QRC.replace('>Main.qml<', '>Hidden.qml<')}) + md2 = qt_metadata(sites(result2, "qml_load")[0]) + assert (md2["status"], md2["reason"]) == ("unavailable", "resource_target_outside_corpus") diff --git a/tests/test_qt_project_membership.py b/tests/test_qt_project_membership.py new file mode 100644 index 0000000000..6314aca2a6 --- /dev/null +++ b/tests/test_qt_project_membership.py @@ -0,0 +1,283 @@ +"""REQ-QML-020 publishes literal metadata membership without runtime guesses.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import extract +from graphify.extractors.qml_facts import encode_metadata, qml_metadata +from graphify.paths import load_node_link_graph +from graphify.qt_project_index import QtProjectIndex + + +def fixture(root, system="cmake", resource="resources.qrc"): + """An unused frontend still has explicit build and resource declarations.""" + sources = { + "qml/ResizeHandle.qml": "import QtQuick\nItem { id: handle; property int position: 1 }\n", + "backend.cpp": "class Backend {};\n", + resource: '' + 'qml/ResizeHandle.qml', + } + if system == "cmake": + sources["CMakeLists.txt"] = "qt_add_qml_module(app URI Public.Layout VERSION 1.0 " \ + "QML_FILES qml/ResizeHandle.qml SOURCES backend.cpp)\n" + else: + sources["project.pro"] = "QML_IMPORT_NAME = Public.Layout\nQML_IMPORT_VERSION = 1.0\n" \ + "QML_FILES += qml/ResizeHandle.qml\nSOURCES += backend.cpp\nRESOURCES += " + resource + "\n" + paths = [] + for name, text in sources.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="") + paths.append(path) + result = extract(paths, root=root, cache_root=root, parallel=False) + assert not result.get("qml_failures") + return result + + +def facts(result, kind): + return [node for node in result["nodes"] if qml_metadata(node).get("kind") == kind] + + +def apply(root, nodes, edges, declarations, **kwargs): + """Use actual accepted producer records and the production per-run index.""" + from graphify.qt_project_membership import resolve_project_memberships + + results = {} + for node in declarations: + results.setdefault(root / node["source_file"], {"nodes": [], "edges": []})["nodes"].append(node) + index = QtProjectIndex(nodes, edges, root=root) + resolve_project_memberships(results, nodes, edges, root=root, project_index=index, **kwargs) + return [node for result in results.values() for node in result["nodes"] + if qml_metadata(node).get("kind") == "membership_resolution"] + + +@pytest.mark.parametrize("system", ["cmake", "qmake"]) +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml020_ac01_unused_component_has_persisted_build_and_resource_membership(tmp_path, system, directed): + """Real facade/build/reload connects declared source membership, with no QML instantiation.""" + result = fixture(tmp_path, system) + declarations = facts(result, "qt_source") + facts(result, "resource_alias") + sites = facts(result, "membership_resolution") + assert len(sites) == len(declarations) + assert all(qml_metadata(site)["status"] == "resolved" for site in sites) + graph = build_from_json(result, root=tmp_path, directed=directed) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path)) + reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + assert reloaded.is_directed() is directed + component = facts(result, "component")[0]["id"] + for site in sites: + md = qml_metadata(site) + assert reloaded.has_edge(md["declaration_id"], site["id"]) + edge = reloaded.edges[site["id"], md["target_id"]] + assert edge["confidence"] == "EXTRACTED" and edge["relation"] == "references" + # Directed transport owns direction structurally; the undirected form + # needs its explicit logical endpoint hints to retain that information. + if directed: + assert not reloaded.has_edge(md["target_id"], site["id"]) + else: + assert (edge["_src"], edge["_tgt"]) == (site["id"], md["target_id"]) + assert qml_metadata(edge)["span"] == md["span"] + assert any(qml_metadata(site)["target_id"] == component for site in sites) + index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path) + assert index.resolve_url("qrc:/ui/handle.qml").target_id == component + assert index.resolve_component("Public.Layout", "ResizeHandle", 1, 0).target_id == component + + +@pytest.mark.parametrize("corruption", ["missing", "duplicate", "outside_root", "generated", + "conditional", "foreign_module", "duplicate_module", "non_file", + "non_ast_module", "non_ast_target", "non_ast_declaration"]) +def test_req_qml020_ac02_source_projection_rejects_unproved_membership(tmp_path, corruption): + """Wrong paths, source roles and module authority cannot create a guessed endpoint.""" + result = fixture(tmp_path) + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source" + and qml_metadata(node).get("source_kind") == "qml") + target = next(node for node in nodes if qml_metadata(node).get("kind") == "component") + md = qml_metadata(declaration) + if corruption == "missing": + nodes[:] = [node for node in nodes if node["source_file"] != target["source_file"]] + elif corruption == "duplicate": + nodes.append({**copy.deepcopy(target), "id": target["id"] + "_competing"}) + elif corruption == "non_file": + target["metadata"]["qml"] = encode_metadata({"kind": "function"}) + for node in nodes: + if node["source_file"] == target["source_file"]: + node["type"] = "function" + node["_callable"] = True + elif corruption == "foreign_module": + for node in nodes: + if qml_metadata(node).get("kind") == "qt_module": + node["source_file"] = "foreign/CMakeLists.txt" + elif corruption == "duplicate_module": + module = next(node for node in nodes if qml_metadata(node).get("kind") == "qt_module") + nodes.append({**copy.deepcopy(module), "id": module["id"] + "_competing"}) + elif corruption in {"non_ast_module", "non_ast_target", "non_ast_declaration"}: + chosen = (next(node for node in nodes if qml_metadata(node).get("kind") == "qt_module") + if corruption == "non_ast_module" else target if corruption == "non_ast_target" else declaration) + chosen["_origin"] = "semantic" + else: + md[{"outside_root": "value", "generated": "generated", "conditional": "conditional"}[corruption]] = ( + "../../foreign.qml" if corruption == "outside_root" else True) + declaration["metadata"]["qml"] = encode_metadata({key: value for key, value in md.items() if key != "raw_values"}) + borrowed = list(nodes) + before = copy.deepcopy((nodes, edges, declaration)) + sites = apply(tmp_path, nodes, edges, [declaration]) + assert len(sites) == 1 and qml_metadata(sites[0])["status"] != "resolved" + assert not qml_metadata(sites[0])["target_id"] + assert not [edge for edge in edges if edge.get("source") == sites[0]["id"]] + assert declaration == before[2] + assert borrowed == before[0] + + +@pytest.mark.parametrize("corruption", ["duplicate", "locale", "generated", "missing", "outside_root"]) +def test_req_qml020_ac02_resources_reuse_existing_alias_guard_decisions(tmp_path, corruption): + """Membership cannot reinterpret ambiguous/localized resource lookup as runtime authority.""" + result = fixture(tmp_path) + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "resource_alias") + md = qml_metadata(declaration) + if corruption == "duplicate": + nodes.append({**copy.deepcopy(declaration), "id": declaration["id"] + "_competing"}) + elif corruption == "missing": + nodes[:] = [node for node in nodes if node["source_file"] != md["target_path"]] + else: + md[{"locale": "locale", "generated": "generated", "outside_root": "target_path"}[corruption]] = ( + "fr" if corruption == "locale" else "../foreign.qml" if corruption == "outside_root" else True) + declaration["metadata"]["qml"] = encode_metadata({key: value for key, value in md.items() if key != "raw_values"}) + sites = apply(tmp_path, nodes, edges, [declaration]) + assert qml_metadata(sites[0])["status"] != "resolved" + assert not [edge for edge in edges if edge.get("source") == sites[0]["id"]] + + +def test_req_qml020_ac03_fresh_derived_sites_replace_borrowed_state_without_mutation(tmp_path): + """Deleting a provider keeps the site's ID while removing its stale target edge.""" + result = fixture(tmp_path) + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "resource_alias") + old_site = apply(tmp_path, nodes, edges, [declaration])[0] + retained = copy.deepcopy(old_site) + target_file = qml_metadata(declaration)["target_path"] + nodes[:] = [node for node in nodes if node["source_file"] != target_file] + new_site = apply(tmp_path, nodes, edges, [declaration])[0] + assert old_site == retained and old_site is not new_site and new_site["id"] == old_site["id"] + assert qml_metadata(new_site)["status"] != "resolved" + assert not [edge for edge in edges if edge.get("source") == new_site["id"]] + snapshot = copy.deepcopy((nodes, edges)) + apply(tmp_path, nodes, edges, [declaration]) + assert (nodes, edges) == snapshot + + +def test_req_qml020_ac01_metadata_spans_survive_bom_crlf_unicode_and_sanitation(tmp_path): + """Projection carries original declaration byte offsets and decoded alias punctuation.""" + component = tmp_path / "qml/ResizeHandle.qml" + component.parent.mkdir() + component.write_text("Item {}", encoding="utf-8") + resource = tmp_path / "resources.qrc" + raw = '\ufeff\r\n\r\nqml/ResizeHandle.qml\r\n\r\n'.encode("utf-8") + resource.write_bytes(raw) + result = extract([component, resource], root=tmp_path, cache_root=tmp_path, parallel=False) + site = facts(result, "membership_resolution")[0] + span = qml_metadata(site)["span"] + assert raw[span["start_byte"]:span["end_byte"]] == b'qml/ResizeHandle.qml' + graph = build_from_json(result, root=tmp_path) + assert qml_metadata(graph.nodes[site["id"]])["logical_url"] == "qrc:/ui/handle&.qml" + + +def test_req_qml020_ac02_corrupt_literal_transport_is_rejected(tmp_path): + """Corruption fails at the actual seam instead of resolving escaped display data.""" + result = fixture(tmp_path) + declaration = facts(result, "resource_alias")[0] + declaration["metadata"]["qml"]["raw_values"]["target_path"] = "invalid!" + with pytest.raises(ValueError, match="QML_METADATA"): + apply(tmp_path, result["nodes"], result["edges"], [declaration]) + + +def test_req_qml020_ac01_scoped_paths_repeated_declarations_and_query_keep_exact_identity(tmp_path): + """Equal basenames and repeated declarations keep distinct evidence and source scope.""" + from graphify.serve import _query_graph_text + + paths = [tmp_path / "left/Handle.qml", tmp_path / "right/Handle.qml", tmp_path / "CMakeLists.txt"] + for path in paths[:2]: + path.parent.mkdir() + path.write_text("Item {}", encoding="utf-8") + paths[2].write_text("qt_add_qml_module(app URI Public.Layout VERSION 1.0 " + "QML_FILES left/Handle.qml right/Handle.qml left/Handle.qml)", encoding="utf-8") + source_bytes = {path: path.read_bytes() for path in paths} + result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + declarations, sites = facts(result, "qt_source"), facts(result, "membership_resolution") + assert len(declarations) == len(sites) == 3 and len({site["id"] for site in sites}) == 3 + components = {node["source_file"]: node["id"] for node in facts(result, "component")} + assert [qml_metadata(site)["target_id"] for site in sites].count(components["left/Handle.qml"]) == 2 + assert [qml_metadata(site)["target_id"] for site in sites].count(components["right/Handle.qml"]) == 1 + before = copy.deepcopy(declarations) + index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path) + lookup = index.resolve_component("Public.Layout", "Handle", 1, 0) + assert lookup.status == "ambiguous" + apply(tmp_path, result["nodes"], result["edges"], declarations) + assert declarations == before + assert QtProjectIndex(result["nodes"], result["edges"], root=tmp_path).resolve_component( + "Public.Layout", "Handle", 1, 0) == lookup + graph = build_from_json(result, root=tmp_path) + answer = _query_graph_text(graph, "left/Handle.qml", token_budget=8000) + assert "left/Handle.qml" in answer and "CMakeLists.txt" in answer and "references" in answer + assert source_bytes == {path: path.read_bytes() for path in paths} + + +def test_req_qml020_ac02_projection_reads_no_targets_and_executes_no_corpus(tmp_path, monkeypatch): + """Source parsing precedes this guard; the index/join uses only accepted dictionaries.""" + import builtins + import subprocess + from pathlib import Path + from graphify.qt_project_membership import resolve_project_memberships + + result = fixture(tmp_path) + declarations = facts(result, "qt_source") + facts(result, "resource_alias") + active = {} + for node in declarations: + active.setdefault(tmp_path / node["source_file"], {"nodes": [], "edges": []})["nodes"].append(node) + before = copy.deepcopy(declarations) + + def forbidden(*_args, **_kwargs): + raise AssertionError("Membership projection may not read, discover or execute target sources") + + for method in ("open", "read_bytes", "read_text", "glob", "rglob"): + monkeypatch.setattr(Path, method, forbidden) + monkeypatch.setattr(builtins, "open", forbidden) + monkeypatch.setattr(subprocess, "run", forbidden) + monkeypatch.setattr(subprocess, "Popen", forbidden) + index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path) + sites, _ = resolve_project_memberships(active, result["nodes"], result["edges"], root=tmp_path, project_index=index) + assert sites and all(qml_metadata(site)["status"] == "resolved" for site in sites) + assert declarations == before + + +@pytest.mark.parametrize("bad_location", ["L10", "L1-L999"]) +def test_req_qml020_ac02_location_prefix_cannot_authorize_another_source_span(tmp_path, bad_location): + """A line prefix cannot substitute for the exact transported declaration location.""" + result = fixture(tmp_path) + declaration = facts(result, "resource_alias")[0] + assert declaration["source_location"] == "L1" + declaration["source_location"] = bad_location + with pytest.raises(ValueError, match="QML_METADATA"): + apply(tmp_path, result["nodes"], result["edges"], [declaration]) + + +def test_req_qml020_ac03_typed_file_role_survives_punctuation_and_borrowed_publication(tmp_path): + """Actual publication retains the file label/path while literal metadata survives sanitation.""" + result = fixture(tmp_path, "qmake", "res&ources.qrc") + declaration = next(node for node in facts(result, "qt_source") if qml_metadata(node)["source_kind"] == "qrc") + path = tmp_path / "graph.json" + assert to_json(build_from_json(result, root=tmp_path), {}, str(path)) + raw = json.loads(path.read_text(encoding="utf-8")) + nodes, edges = raw["nodes"], raw.get("links", raw.get("edges")) + target = next(node for node in nodes if node["source_file"] == "res&ources.qrc" + and qml_metadata(node).get("kind") == "file") + assert target["label"] == "res&ources.qrc" + site = apply(tmp_path, nodes, edges, [declaration])[0] + assert qml_metadata(site)["status"] == "resolved" and qml_metadata(site)["target_id"] == target["id"] diff --git a/tests/test_qt_project_membership_aliases.py b/tests/test_qt_project_membership_aliases.py new file mode 100644 index 0000000000..5d5003134f --- /dev/null +++ b/tests/test_qt_project_membership_aliases.py @@ -0,0 +1,225 @@ +"""REQ-QML-020: real filesystem aliases preserve accepted membership identity.""" +from __future__ import annotations + +import copy +import ctypes +import os +from pathlib import Path + +import pytest + +from graphify.build import build_from_json +from graphify.extract import extract +from graphify.extractors.qml_facts import qml_metadata +from graphify.qml_resolution_types import source_path +from graphify.qt_project_index import QtProjectIndex +from graphify.qt_project_membership import resolve_project_memberships +from tests.qml_installed_smoke import native_module_smoke +from tests.test_qt_final_incremental_parity import clean, normalized, run + + +NAMES = ("backend.h", "Main.qml", "loader.cpp", "CMakeLists.txt", "resources.qrc") + + +@pytest.fixture(params=["windows_short", "symlink"]) +def aliased_parent(tmp_path, request): + """Exercise host path aliases without substituting a fake Path resolver.""" + parent = tmp_path / "parent directory with spaces" + parent.mkdir() + if request.param == "symlink": + request.getfixturevalue("requires_symlinks") + alias = tmp_path / "parent-alias" + alias.symlink_to(parent, target_is_directory=True) + else: + if os.name != "nt": + pytest.skip("Windows short-path API is not applicable on this host") + api = ctypes.windll.kernel32.GetShortPathNameW + api.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32) + api.restype = ctypes.c_uint32 + buffer = ctypes.create_unicode_buffer(32768) + length = api(str(parent), buffer, len(buffer)) + if not length or length >= len(buffer) or buffer.value == str(parent): + pytest.skip("Filesystem does not supply a distinct Windows short-path alias") + alias = Path(buffer.value) + assert alias != parent and alias.resolve() == parent.resolve() + return parent, alias + + +def extract_graph(root, anchor, cache, extra=()): + """The actual facade publishes the mixed-language accepted graph.""" + result = extract([root / name for name in (*NAMES, *extra)], root=anchor, + cache_root=cache, parallel=False) + assert not result["failed_sources"] and not result["qml_failures"] + return build_from_json(result, root=anchor, directed=True), result + + +def memberships(graph): + return {identity: qml_metadata(data) for identity, data in graph.nodes(data=True) + if qml_metadata(data).get("kind") == "membership_resolution"} + + +def test_req_qml020_ac01_real_parent_alias_preserves_smoke_and_cold_warm_identity( + aliased_parent, tmp_path, monkeypatch, request): + """Unchanged smoke and a warm unrelated cache retain identical Qt endpoints.""" + parent, alias = aliased_parent + canonical, supplied = parent / "native-profile", alias / "native-profile" + native_module_smoke(supplied) + (canonical / "keep.py").write_bytes(b"def retained(): return 7\n") + names = (*NAMES, "keep.py") + before = {name: (canonical / name).read_bytes() for name in names} + expected, _ = extract_graph(canonical, canonical, tmp_path / "canonical-cache", ("keep.py",)) + cold, cold_result = extract_graph(supplied, supplied, tmp_path / "alias-cache", ("keep.py",)) + assert normalized(cold) == normalized(expected) + sites = memberships(cold) + assert len(sites) == 4 and all(md["status"] == "resolved" for md in sites.values()) + + # Qt policy deliberately reparses native/QML/metadata facts. The unrelated + # Python file must hit its actual cache while these fresh joins retain IDs. + import graphify.extract as extraction + observed, real = [], extraction._safe_extract_with_xaml_root + + def observe(extractor, path, root): + observed.append(path) + return real(extractor, path, root) + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe) + warm, warm_result = extract_graph(supplied, supplied, tmp_path / "alias-cache", ("keep.py",)) + # Native alternate entry spellings expand before dispatch; a separately + # discovered symlink keeps its exact lexical owner. Python still hits cache. + parser_root = canonical if request.node.callspec.params["aliased_parent"] == "windows_short" else supplied + assert set(observed) == {parser_root / name for name in NAMES} + assert normalized(warm) == normalized(cold) + assert memberships(warm) == sites + assert {node["id"] for node in warm_result["nodes"]} == { + node["id"] for node in cold_result["nodes"]} + reanchored, _ = extract_graph(supplied, canonical, tmp_path / "reanchored-cache", ("keep.py",)) + assert normalized(reanchored) == normalized(expected) and memberships(reanchored) == sites + assert before == {name: (canonical / name).read_bytes() for name in names} + # Stored provenance is a lexical, read-free contract. Only accepted input + # path identity at the membership boundary should resolve filesystem aliases. + assert source_path({"source_file": str(supplied / "CMakeLists.txt")}, canonical) is None + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml020_ac03_alias_updates_remove_stale_membership_and_match_full_rebuild( + aliased_parent, tmp_path, monkeypatch, operation): + """Real update writers retire a removed build declaration through the supplied alias.""" + parent, alias = aliased_parent + canonical, supplied = parent / "native-profile", alias / "native-profile" + native_module_smoke(supplied) + monkeypatch.chdir(canonical) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(supplied, monkeypatch, operation) + assert normalized(initial) == normalized(clean(canonical, tmp_path / "initial-cache")) + old = memberships(initial) + assert len(old) == 4 and all(md["status"] == "resolved" for md in old.values()) + build = supplied / "CMakeLists.txt" + build.write_bytes(build.read_bytes().replace(b"QML_FILES Main.qml", b"QML_FILES")) + updated = run(supplied, monkeypatch, operation, [build]) + retired = set(old) - set(memberships(updated)) + assert len(retired) == 1 and not retired.intersection(updated) + assert normalized(updated) == normalized(clean(canonical, tmp_path / "changed-cache")) + assert normalized(run(supplied, monkeypatch, operation, [])) == normalized(updated) + for source, target, data in updated.edges(data=True): + if data.get("context") == "qt_membership_site": + assert source not in retired and target not in retired + + +@pytest.mark.parametrize("corruption", ["outside_root", "conflicting_source"]) +def test_req_qml020_ac02_path_alias_cannot_authorize_foreign_source( + aliased_parent, tmp_path, corruption): + """A real outside/other source path still raises the established integrity diagnostic.""" + parent, alias = aliased_parent + canonical, supplied = parent / "native-profile", alias / "native-profile" + native_module_smoke(canonical) + _, result = extract_graph(canonical, canonical, tmp_path / "accepted-cache") + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source") + if corruption == "outside_root": + foreign = parent / "foreign/CMakeLists.txt" + foreign.parent.mkdir() + foreign.write_bytes((canonical / "CMakeLists.txt").read_bytes()) + path = alias / "foreign/CMakeLists.txt" + else: + path = supplied / "other.cmake" + path.write_bytes((canonical / "CMakeLists.txt").read_bytes()) + active = {path: {"nodes": [declaration], "edges": []}} + index = QtProjectIndex(nodes, edges, root=canonical) + before = copy.deepcopy((nodes, edges, active)) + with pytest.raises(ValueError, match="QML_METADATA: membership declaration source is not accepted"): + resolve_project_memberships(active, nodes, edges, root=canonical, project_index=index) + assert (nodes, edges, active) == before + + +@pytest.mark.parametrize("error_type", [OSError, RuntimeError]) +def test_req_qml020_ac02_input_resolution_failure_is_bounded_and_preserves_facts( + tmp_path, monkeypatch, error_type): + """Filesystem failure/alias loops cannot leak backend bodies or partially publish.""" + canonical = tmp_path / "native-profile" + native_module_smoke(canonical) + _, result = extract_graph(canonical, canonical, tmp_path / "accepted-cache") + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source") + build = canonical / "CMakeLists.txt" + active = {build: {"nodes": [declaration], "edges": []}} + index = QtProjectIndex(nodes, edges, root=canonical) + before = copy.deepcopy((nodes, edges, active)) + real, observed = Path.resolve, [] + private_body = "private-filesystem-backend-body" + + def fail_input_only(path, *args, **kwargs): + # Accepted parsing/indexing is complete. Fail only this supplied input + # identity operation; root and all other real path behavior stay intact. + if path == build: + observed.append(path) + raise error_type(private_body) + return real(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", fail_input_only) + with pytest.raises(ValueError) as failure: + resolve_project_memberships(active, nodes, edges, root=canonical, project_index=index) + assert str(failure.value) == "QML_METADATA: membership declaration source is not accepted" + assert private_body not in repr(failure.value) and failure.value.__cause__ is None + assert observed == [build] and (nodes, edges, active) == before + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml020_ac02_conflicting_transport_retains_durable_products_and_recovers( + aliased_parent, tmp_path, monkeypatch, operation): + """Rejected accepted-input transport preserves saved graph/manifest/stamp/root products.""" + import graphify.qt_project_membership as membership + from graphify.watch import _rebuild_code + + parent, alias = aliased_parent + canonical, supplied = parent / "native-profile", alias / "native-profile" + native_module_smoke(supplied) + monkeypatch.chdir(canonical) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(supplied, monkeypatch, operation) + output = canonical / "graphify-out" + products = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root") + before = {name: (output / name).read_bytes() for name in products} + build = supplied / "CMakeLists.txt" + source = build.read_bytes() + build.write_bytes(source + b"\n# trigger accepted-source refresh\n") + real = membership.resolve_project_memberships + + def conflict(results, *args, **kwargs): + # Corrupt only the transported input owner. Real parsing, source facts, + # per-run indexes, diagnostic handling, and durable writers still run. + wrong = {Path(path).with_name("other.cmake"): value for path, value in results.items()} + return real(wrong, *args, **kwargs) + + with monkeypatch.context() as rejected: + rejected.setattr(membership, "resolve_project_memberships", conflict) + if operation == "manual": + with pytest.raises(SystemExit) as failure: + run(supplied, rejected, operation, [build]) + assert failure.value.code == 1 + else: + assert not _rebuild_code(supplied, changed_paths=[build], no_cluster=True) + assert before == {name: (output / name).read_bytes() for name in products} + build.write_bytes(source) + repaired = run(supplied, monkeypatch, operation, [build]) + assert normalized(repaired) == normalized(initial) + assert normalized(run(supplied, monkeypatch, operation, [])) == normalized(initial) diff --git a/tests/test_qt_project_membership_updates.py b/tests/test_qt_project_membership_updates.py new file mode 100644 index 0000000000..26c070dd5b --- /dev/null +++ b/tests/test_qt_project_membership_updates.py @@ -0,0 +1,299 @@ +"""REQ-QML-020 production refresh, persistence and unused-component regression.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.extractors.qml_facts import qml_metadata +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated +from tests.test_qt_graph_html_payload import payload, runtime_info + + +def fixture(root): + """An uninstantiated QML component is packaged by accepted literal metadata.""" + sources = { + "Spare.qml": "import QtQuick\nItem { id: grip; property int value: 1 }\n", + "helper.cpp": "int helper() { return 7; }\n", + "CMakeLists.txt": "qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Spare.qml SOURCES helper.cpp)\n", + "ui.qrc": 'Spare.qml', + "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n", + } + for name, source in sources.items(): + (root / name).write_bytes(source.encode()) + return root / "Spare.qml" + + +def memberships(graph): + return {identity: qml_metadata(data) for identity, data in graph.nodes(data=True) + if qml_metadata(data).get("kind") == "membership_resolution"} + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml020_ac03_metadata_resource_and_source_updates_remove_stale_memberships(tmp_path, monkeypatch, operation): + """Real full/warm/manual/watch graphs agree after metadata edits and target removal.""" + target = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + cold = clean(tmp_path, tmp_path / ".cold-cache") + assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold) + graph = run(tmp_path, monkeypatch, operation) + assert normalized(graph) == normalized(cold) + original = memberships(graph) + assert len(original) == 3 and all(md["status"] == "resolved" for md in original.values()) + module = next(identity for identity, node in graph.nodes(data=True) if qml_metadata(node).get("kind") == "qt_module") + component = next(identity for identity, node in graph.nodes(data=True) if qml_metadata(node).get("kind") == "component") + import networkx as nx + assert nx.has_path(graph, module, component) + kept = unrelated(graph) + + # Renaming a resource alias replaces its source-owned resolution site, + # without changing the accepted QML component identity or unrelated Python. + resource = tmp_path / "ui.qrc" + resource.write_bytes(resource.read_bytes().replace(b'alias="Spare.qml"', b'alias="renamed.qml"')) + renamed = run(tmp_path, monkeypatch, operation, [resource]) + retired = set(original) - set(memberships(renamed)) + assert len(retired) == 1 and not retired.intersection(renamed) + assert component in renamed and unrelated(renamed) == kept + assert normalized(renamed) == normalized(clean(tmp_path, tmp_path / ".renamed-cache")) + + # Duplicate logical aliases invalidate both resource endpoint decisions, + # even when both literal declarations still name the same accepted target. + valid_resource = resource.read_bytes() + resource.write_bytes(valid_resource.replace(b"", + b'Spare.qml')) + ambiguous = run(tmp_path, monkeypatch, operation, [resource]) + resource_sites = [md for md in memberships(ambiguous).values() if md.get("alias")] + assert len(resource_sites) == 2 and all(md["status"] == "ambiguous" for md in resource_sites) + assert all(not md.get("target_id") for md in resource_sites) + assert normalized(ambiguous) == normalized(clean(tmp_path, tmp_path / ".ambiguous-cache")) + resource.write_bytes(valid_resource) + assert normalized(run(tmp_path, monkeypatch, operation, [resource])) == normalized(renamed) + + # Editing and then renaming the packaged source updates its canonical facts + # and both metadata paths; old endpoints cannot survive their retirement. + target.write_bytes(target.read_bytes().replace(b"value: 1", b"value: 2")) + edited = run(tmp_path, monkeypatch, operation, [target]) + assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited-cache")) + assert unrelated(edited) == kept + old_target = target + target = tmp_path / "Renamed.qml" + old_target.rename(target) + build = tmp_path / "CMakeLists.txt" + build.write_bytes(build.read_bytes().replace(b"Spare.qml", b"Renamed.qml")) + resource.write_bytes(resource.read_bytes().replace(b">Spare.qml<", b">Renamed.qml<")) + renamed_target = run(tmp_path, monkeypatch, operation, [old_target, target, build, resource]) + assert component not in renamed_target + component = next(identity for identity, node in renamed_target.nodes(data=True) + if qml_metadata(node).get("kind") == "component") + assert all(md["status"] == "resolved" for md in memberships(renamed_target).values()) + assert normalized(renamed_target) == normalized(clean(tmp_path, tmp_path / ".target-renamed-cache")) + assert unrelated(renamed_target) == kept + + # Removing an accepted target invalidates unchanged module/alias statements; + # no fallback to a same-named source or external read can retain their edges. + target.unlink() + removed = run(tmp_path, monkeypatch, operation, [target]) + assert component not in removed + remaining = memberships(removed) + missing = [md for md in remaining.values() if md.get("status") != "resolved"] + assert len(missing) == 2 and all(not md.get("target_id") for md in missing) + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache")) + assert unrelated(removed) == kept + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed) + + +@pytest.mark.parametrize("operation", ["manual", "force", "watch"]) +def test_req_qml020_ac02_failed_metadata_join_preserves_products_and_recovers(tmp_path, monkeypatch, operation): + """An actual malformed resource preserves four durable products; byte repair is repeatable.""" + fixture(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + mode = "manual" if operation == "force" else operation + initial = run(tmp_path, monkeypatch, mode) + assert memberships(initial) + output = tmp_path / "graphify-out" + names = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root") + before = {name: (output / name).read_bytes() for name in names} + path = tmp_path / "ui.qrc" + valid = path.read_bytes() + path.write_bytes(valid + b"") + if operation in {"manual", "force"}: + with pytest.raises(SystemExit) as failure: + if operation == "force": + from tests.test_qt_cpp_upgrade_invalidation import cli + cli(tmp_path, monkeypatch, "update", force=True) + else: + run(tmp_path, monkeypatch, mode, [path]) + assert failure.value.code == 1 + else: + from graphify.watch import _rebuild_code + assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True) + assert before == {name: (output / name).read_bytes() for name in names} + path.write_bytes(valid) + corrected = run(tmp_path, monkeypatch, mode, [path]) + assert normalized(corrected) == normalized(initial) + assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(initial) + assert json.loads((output / "graph.json").read_text(encoding="utf-8"))["nodes"] + + +@pytest.mark.parametrize("operation", ["extract", "update"]) +def test_req_qml020_ac03_policy_upgrade_refreshes_unchanged_packaging(tmp_path, monkeypatch, operation): + """A policy-five stamp cannot retain old decisions at the same package version.""" + import graphify.extract as extraction + import graphify.qt_incremental as policy + from graphify.qt_analysis_state import inspect_qt_analysis + from tests.test_qt_cpp_upgrade_invalidation import cli + + fixture(tmp_path) + monkeypatch.chdir(tmp_path) + paths = [tmp_path / name for name in ("Spare.qml", "helper.cpp", "CMakeLists.txt", "ui.qrc", "keep.py")] + with monkeypatch.context() as previous: + previous.setattr(policy, "QT_POLICY_VERSION", 5) + before = cli(tmp_path, previous, "extract") + assert not inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed + sources = {path: path.read_bytes() for path in paths} + assert inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed + observed, real = [], extraction._safe_extract_with_xaml_root + + def observe(extractor, path, root): + observed.append(path) + return real(extractor, path, root) + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe) + current = cli(tmp_path, monkeypatch, operation) + assert set(paths[:4]).issubset(observed) + assert not inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed + assert len(memberships(current)) == 3 + assert normalized(current) == normalized(before) + assert normalized(current) == normalized(clean(tmp_path, tmp_path / ".upgrade-cache")) + assert sources == {path: path.read_bytes() for path in paths} + assert normalized(cli(tmp_path, monkeypatch, operation)) == normalized(current) + + +def test_req_qml020_ac02_join_exception_cannot_publish_partial_memberships(tmp_path, monkeypatch): + """A failure after real scratch projection preserves graph, manifest, stamp and root.""" + import graphify.qt_project_membership as membership + + fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, "manual") + output = tmp_path / "graphify-out" + products = [output / name for name in ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")] + before = {path: path.read_bytes() for path in products} + path = tmp_path / "CMakeLists.txt" + original = path.read_bytes() + path.write_bytes(original + b"\n# refresh projection\n") + real = membership.resolve_project_memberships + + def fail_after_real_join(*args, **kwargs): + result = real(*args, **kwargs) + assert result[0] + raise RuntimeError("injected join completion failure") + + with monkeypatch.context() as failure: + failure.setattr(membership, "resolve_project_memberships", fail_after_real_join) + with pytest.raises(SystemExit) as rejected: + run(tmp_path, failure, "manual", [path]) + assert rejected.value.code == 1 + assert before == {path: path.read_bytes() for path in products} + path.write_bytes(original) + assert normalized(run(tmp_path, monkeypatch, "manual", [path])) == normalized(initial) + + +def test_req_qml020_ac01_unused_packaged_component_survives_aggregate_export(tmp_path, monkeypatch): + """Actual serialized facts yield aggregate links and truthful source-edge counts.""" + from graphify.export import to_html + + fixture(tmp_path) + monkeypatch.chdir(tmp_path) + graph = run(tmp_path, monkeypatch, "manual") + files = sorted({node["source_file"] for _, node in graph.nodes(data=True)}) + groups = {number: [identity for identity, node in graph.nodes(data=True) if node["source_file"] == path] + for number, path in enumerate(files)} + number = files.index("Spare.qml") + path = tmp_path / "aggregate.html" + assert to_html(graph, groups, str(path), node_limit=1, learning_overlay={}) + content = path.read_text(encoding="utf-8") + result = {"nodes": payload(content, "RAW_NODES"), "info": runtime_info(content, str(number), monkeypatch)} + selected = next(node for node in result["nodes"] if node["id"] == str(number)) + assert selected["external_source_edges"] == 2 and selected["degree"] == 2 + assert selected["internal_source_edges"] > 0 + # Startup selection belongs to deferred REQ-QML-019-AC04; source counts remain required. + assert "External source edges: 2" in result["info"] + assert sum(node["internal_source_edges"] for node in result["nodes"]) + sum( + node["external_source_edges"] for node in result["nodes"]) // 2 == graph.number_of_edges() + + +@pytest.mark.parametrize("invalid", [True, -1, ""]) +def test_req_qml020_ac01_aggregate_counts_keep_source_edges_and_reject_invalid_fields(tmp_path, monkeypatch, invalid): + """Parallel/directed/self-loop edges count once; untrusted aggregate fields remain unavailable.""" + import copy + import networkx as nx + from graphify.export import to_html + + graph = nx.MultiDiGraph() + graph.add_edges_from([("a0", "a1"), ("a1", "a0"), ("a0", "a0"), ("a0", "b0"), + ("b0", "a0"), ("a0", "a1"), ("a1", "b0")]) + before = copy.deepcopy(graph) + path = tmp_path / "counts.html" + assert to_html(graph, {0: ["a0", "a1"], 1: ["b0"]}, str(path), node_limit=1, learning_overlay={}) + nodes = {node["id"]: node for node in payload(path.read_text(encoding="utf-8"), "RAW_NODES")} + assert nodes["0"]["internal_source_edges"] == 4 and nodes["1"]["internal_source_edges"] == 0 + assert nodes["0"]["external_source_edges"] == nodes["1"]["external_source_edges"] == 3 + assert sum(n["internal_source_edges"] for n in nodes.values()) + sum( + n["external_source_edges"] for n in nodes.values()) // 2 == graph.number_of_edges() + assert nx.utils.graphs_equal(graph, before) + meta = nx.Graph([("0", "1")]) + meta.nodes["0"].update(internal_source_edges=invalid, external_source_edges=invalid) + assert to_html(meta, {0: ["0"], 1: ["1"]}, str(path), member_counts={0: 4, 1: 2}, learning_overlay={}) + content = path.read_text(encoding="utf-8") + assert all("internal_source_edges" not in n and "external_source_edges" not in n + for n in payload(content, "RAW_NODES")) + info = runtime_info(content, "0", monkeypatch) + assert "Internal source edges: unavailable" in info and "External source edges: unavailable" in info + assert "bad()" not in info and "Degree: 1" in info + + +def test_req_qml020_ac03_membership_ids_are_independent_of_checkout_location(tmp_path): + """Accepted relative metadata and QML facts remain identical after root relocation.""" + first, second = tmp_path / "first", tmp_path / "relocated" + first.mkdir() + second.mkdir() + fixture(first) + fixture(second) + original = clean(first, tmp_path / ".first-cache") + relocated = clean(second, tmp_path / ".second-cache") + assert len(memberships(original)) == 3 + assert normalized(original) == normalized(relocated) + + +def test_req_qml020_ac03_pipeline_publishes_replacement_without_borrowed_mutation(tmp_path): + """Rejoining fresh metadata replaces a borrowed site before the former list boundary.""" + import copy + from graphify.extract import extract + from graphify.extractors.qml_resources import extract_qrc + from graphify.qt_qml_pipeline import resolve_qt_qml + + fixture(tmp_path) + paths = [tmp_path / name for name in ("Spare.qml", "helper.cpp", "CMakeLists.txt", "ui.qrc")] + prior = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + old_site = next(node for node in prior["nodes"] if qml_metadata(node).get("kind") == "membership_resolution" + and qml_metadata(node).get("alias")) + old_id = old_site["id"] + context = [node for node in prior["nodes"] if node["source_file"] != "Spare.qml"] + context_ids = {node["id"] for node in context} + context_edges = [edge for edge in prior["edges"] + if edge["source"] in context_ids and edge["target"] in context_ids] + before = copy.deepcopy((context, context_edges)) + fresh = extract_qrc(paths[-1], root=tmp_path) + nodes, edges = list(fresh["nodes"]), list(fresh["edges"]) + assert not resolve_qt_qml([paths[-1]], [fresh], nodes, edges, root=tmp_path, + context_nodes=context, context_edges=context_edges) + replacement = next(node for node in nodes if node["id"] == old_id) + assert qml_metadata(replacement)["status"] == "unavailable" + assert qml_metadata(old_site)["status"] == "resolved" + assert (context, context_edges) == before + assert not any(edge["source"] == old_id for edge in edges) + assert {node["source_file"] for node in nodes} == {"ui.qrc"} + assert sum(node["id"] == old_id for node in nodes) == 1 diff --git a/tests/test_qt_project_metadata.py b/tests/test_qt_project_metadata.py new file mode 100644 index 0000000000..c68db9ba64 --- /dev/null +++ b/tests/test_qt_project_metadata.py @@ -0,0 +1,146 @@ +"""QML-009-AC01/04: literal build provenance and fail-closed scope boundaries.""" +import json + +import pytest + +from graphify.extractors.qml_cmake import extract_cmake, parse_cmake +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qml_project_read import MAX_PROJECT_BYTES +from graphify.extractors.qml_qmake import extract_qmake, parse_qmake +from graphify.security import sanitize_metadata + + +def records(result, kind): + return [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == kind] + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +@pytest.mark.parametrize("bom", ["", "\ufeff"]) +def test_literal_cmake_records_original_bytes_and_transport(tmp_path, newline, bom): + """Both Qt command names preserve URI/version, Unicode paths and exact ranges.""" + source = bom + newline.join([ + "# α", "cmake_minimum_required(VERSION 3.21)", "project(PublicTools)", + "qt6_add_qml_module(tools", " URI Public.Tools VERSION 1.2 RESOURCE_PREFIX /ui", + ' QML_FILES Main.qml "Écran.qml" SOURCES service.h service.cpp', + " RESOURCES icon.svg IMPORTS Public.Base/1.0 QtQuick/auto", + " DEPENDENCIES Public.Core/1 OPTIONAL_IMPORTS Public.Optional", + " IMPORT_PATH imports NO_PLUGIN)", "",]) + path = tmp_path / "CMakeLists.txt" + path.write_bytes(source.encode("utf-8")) + result = extract_cmake(path, root=tmp_path) + assert not result.get("error") + module = records(result, "qt_module")[0] + assert (module["uri"], module["major"], module["minor"], module["resource_prefix"]) == ( + "Public.Tools", 1, 2, "/ui") + assert [md["value"] for md in records(result, "qt_source")] == [ + "Main.qml", "Écran.qml", "service.h", "service.cpp", "icon.svg"] + imports = records(result, "qt_module_import") + assert [(md["uri"], md["visibility"]) for md in imports] == [ + ("Public.Base", "import"), ("QtQuick", "import"), + ("Public.Core", "dependencies"), ("Public.Optional", "optional_imports")] + assert imports[1]["auto"] is True + raw = source.encode("utf-8") + for node in result["nodes"]: + md = qml_metadata(node) + span = md["span"] + for end in ("start", "end"): + prefix = raw[:span[f"{end}_byte"]] + assert span[f"{end}_row"] == prefix.count(b"\n") + assert span[f"{end}_column"] == len(prefix) - prefix.rfind(b"\n") - 1 + if md["kind"] == "qt_source": + assert raw[span["start_byte"]:span["end_byte"]].decode().strip('"') == md["value"] + sanitized = json.loads(json.dumps(result)) + for item in sanitized["nodes"] + sanitized["edges"]: + item["metadata"] = sanitize_metadata(item["metadata"]) + assert records(sanitized, "qt_source") == records(result, "qt_source") + assert {edge["source"] for edge in result["edges"]} <= {node["id"] for node in result["nodes"]} + + +@pytest.mark.parametrize("source,reason", [ + ("if(WIN32)\nqt_add_qml_module(app URI Hidden VERSION 1.0 QML_FILES A.qml)\nendif()", "conditional_or_expanded_qml_module"), + ("function(build)\nqt_add_qml_module(app URI Hidden VERSION 1.0)\nendfunction()", "conditional_or_expanded_qml_module"), + ("qt_add_qml_module(app URI Public.Tools VERSION ${VERSION})", "conditional_or_expanded_qml_module"), + ("qt_add_qml_module(app URI Public.Tools SOURCES $)", "conditional_or_expanded_qml_module"), + ("qt_add_qml_module(app URI Public.Tools VERSION 1.0 IMPORTS TARGET Other)", "target_import_requires_policy_and_target_resolution"), + ("qt_add_qml_module(app URI Public.Tools UNKNOWN feature)", "unsupported_qml_module_arguments"), + ("qt_add_qml_module(app URI Public.Tools VERSION 1.0)\ntarget_sources(app PRIVATE generated.h)", "source_mutation_outside_literal_subset"), +]) +def test_cmake_conditional_and_expanded_metadata_is_not_authoritative(source, reason): + result = parse_cmake(source) + assert result["partial"] is True + assert reason in {item["reason"] for item in result["diagnostics"]} + assert result["qml_failures"] + if "Hidden" in source: + assert records(result, "qt_module") == [] + + +def test_comments_strings_and_unknown_builds_do_not_make_modules(): + source = '# qt_add_qml_module(fake URI Fake)\nmessage("qt_add_qml_module(fake URI Fake)")\n' + source += '#[[ qt_add_qml_module(fake URI Fake) ]]\nproject(Ordinary)\n' + result = parse_cmake(source) + assert not result.get("error") + assert records(result, "qt_module") == [] + assert parse_cmake("if(WIN32)\nproject(Ordinary)")["qml_failures"] + + +def test_qmake_literal_module_sources_resource_and_tooling_paths(tmp_path): + source = '\ufeff# α\r\nQT += qml quick\r\nCONFIG += qmltypes\r\nTARGET = tools\r\n' + source += 'QML_IMPORT_NAME = Public.Tools\r\nQML_IMPORT_VERSION = 1.2\r\n' + source += 'SOURCES += service.cpp \\\r\n worker.cpp\r\nHEADERS += service.h\r\n' + source += 'RESOURCES += ui.qrc\r\nQML_IMPORT_PATH += imports\r\nDISTFILES += "Écran.qml"\r\n' + path = tmp_path / "tools.pro" + path.write_bytes(source.encode()) + result = extract_qmake(path, root=tmp_path) + assert not result.get("error") + module = records(result, "qt_module")[0] + assert (module["uri"], module["major"], module["minor"]) == ("Public.Tools", 1, 2) + assert [md["value"] for md in records(result, "qt_source")] == [ + "service.cpp", "worker.cpp", "service.h", "Écran.qml", "ui.qrc"] + assert records(result, "qt_import_path")[0]["visibility"] == "tooling" + assert result == parse_qmake(source, relative_file="tools.pro") + + +@pytest.mark.parametrize("line", [ + "unix {\n QML_IMPORT_NAME = Hidden\n}", "win32: QML_IMPORT_NAME = Hidden", + "QML_IMPORT_NAME = $$MODULE", "include(other.pri)", + "QML_IMPORT_NAME = Public.Tools\nSOURCES += $$files(*.cpp)", + "QML_IMPORT_NAME = Public.Tools\nSOURCES -= service.cpp", +]) +def test_qmake_conditions_expansion_functions_never_produce_guessed_context(line): + result = parse_qmake("QML_IMPORT_MAJOR_VERSION = 1\n" + line) + assert result["qml_failures"] + assert records(result, "qt_module") == [] + + +def test_qmake_assignment_semantics_and_evidenced_base_minor(): + result = parse_qmake("QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_MAJOR_VERSION = 1\n" + "SOURCES = old.cpp\nSOURCES = new.cpp\nSOURCES += helper.cpp") + module = records(result, "qt_module")[0] + assert module["minor"] == 0 + assert module["version_origin"] == "base_major_registration" + assert module["minor_upper_bound_known"] is False + assert [md["value"] for md in records(result, "qt_source")] == ["new.cpp", "helper.cpp"] + for source in ("QML_IMPORT_NAME = A\nQML_IMPORT_VERSION = 1.0\nQML_IMPORT_MAJOR_VERSION = 2", + "QML_IMPORT_NAME = A\nQML_IMPORT_VERSION = bogus"): + assert parse_qmake(source)["qml_failures"] + + +@pytest.mark.parametrize("reader,name", [(extract_cmake, "CMakeLists.txt"), (extract_qmake, "tools.pro")]) +def test_build_reader_containment_utf8_and_actual_read_limits(tmp_path, monkeypatch, reader, name): + root = tmp_path / "root" + root.mkdir() + outside = tmp_path / name + outside.write_bytes(b"project(Outside)") + assert reader(outside, root=root)["nodes"] == [] + path = root / name + path.write_bytes(b"\xff") + assert reader(path, root=root)["qml_failures"] + path.write_bytes(b"x" * (MAX_PROJECT_BYTES + 1)) + assert reader(path, root=root)["diagnostics"][0]["reason"] == "metadata_size_limit" + + +def test_cmake_ids_are_comment_stable_and_case_distinct(): + source = "qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml main.qml)" + before, after = parse_cmake(source), parse_cmake("# comment\n" + source) + assert [n["id"] for n in before["nodes"]] == [n["id"] for n in after["nodes"]] + assert len({n["id"] for n in before["nodes"]}) == len(before["nodes"]) diff --git a/tests/test_qt_publication_stub_origin.py b/tests/test_qt_publication_stub_origin.py new file mode 100644 index 0000000000..39855785fb --- /dev/null +++ b/tests/test_qt_publication_stub_origin.py @@ -0,0 +1,168 @@ +"""INC-QML-38: raw publication owns provenance of only its newly minted stubs.""" +from __future__ import annotations + +import json +import os + +import pytest + +import graphify.watch as watch +from graphify.cache import cache_dir +from tests.test_qt_initial_repeat import pin_graph_mtime, update + + +PRODUCTS = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root") + + +def corpus(root, language, *, imported=True): + """Write a genuinely cold public corpus; fixture setup never extracts it.""" + if language == "cpp": + (root / "Main.qml").write_bytes(b"import QtQuick\nItem {}\n") + (root / "CMakeLists.txt").write_bytes( + b"qt_add_qml_module(app URI Public.Tools VERSION 1.0 " + b"QML_FILES Main.qml SOURCES backend.cpp)\n") + path = root / "backend.cpp" + source = b'#include "missing.hpp"\n' if imported else b"" + path.write_bytes(source + b"int original() { return 7; }\n") + else: + path = root / "keep.py" + source = b"import missing\n" if imported else b"" + path.write_bytes(source + b"def original(): return 7\n") + return path + + +def payload(root): + return json.loads((root / "graphify-out/graph.json").read_bytes()) + + +def nodes(root): + return {node["id"]: node for node in payload(root)["nodes"]} + + +def prepare(root, monkeypatch, language, *, imported=True): + source = corpus(root, language, imported=imported) + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + assert not (root / "graphify-out/graph.json").exists() + assert not list(cache_dir(root).rglob("*.json")) + return source + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("language", ["cpp", "python"]) +def test_first_raw_stub_has_explicit_semantic_origin(tmp_path, monkeypatch, operation, language): + """Canonical import minting stamps its sourceless placeholder on the first publication.""" + prepare(tmp_path, monkeypatch, language) + assert update(tmp_path, monkeypatch, operation) + published = nodes(tmp_path) + assert published["missing"] == { + "id": "missing", "label": "missing", "type": "external", "external": True, + "file_type": "concept", "source_file": "", "_origin": "semantic"} + source_nodes = [node for node in published.values() if node.get("source_file")] + assert source_nodes and all(node.get("_origin") == "ast" for node in source_nodes) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("language", ["cpp", "python"]) +def test_first_raw_import_repeat_preserves_bytes_mtime_and_genuine_edit(tmp_path, monkeypatch, + operation, language): + """An unchanged refresh is durable-idempotent; a real source rename still publishes.""" + source = prepare(tmp_path, monkeypatch, language) + assert update(tmp_path, monkeypatch, operation) + graph = tmp_path / "graphify-out/graph.json" + original, timestamp = pin_graph_mtime(graph) + assert update(tmp_path, monkeypatch, operation) + assert graph.read_bytes() == original and graph.stat().st_mtime_ns == timestamp + source.write_bytes(source.read_bytes().replace(b"original", b"renamed")) + assert update(tmp_path, monkeypatch, operation, [source]) + assert graph.read_bytes() != original and graph.stat().st_mtime_ns != timestamp + assert any("renamed" in node["label"] for node in nodes(tmp_path).values()) + current, timestamp = pin_graph_mtime(graph) + assert update(tmp_path, monkeypatch, operation) + assert graph.read_bytes() == current and graph.stat().st_mtime_ns == timestamp + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("origin", [None, "manual", "semantic", "ast"]) +def test_existing_authored_endpoint_is_not_stamped_by_minting(tmp_path, monkeypatch, operation, origin): + """Only appended builder nodes are owned here; an existing authored endpoint retains its role.""" + source = prepare(tmp_path, monkeypatch, "cpp") + assert update(tmp_path, monkeypatch, operation) + graph = tmp_path / "graphify-out/graph.json" + existing = payload(tmp_path) + authored = {"id": "authored", "label": "authored", "type": "external", "external": True, + "file_type": "concept", "source_file": "", "_origin": origin, + "metadata": {"ownership": "authored"}} + existing["nodes"].append(authored) + graph.write_text(json.dumps(existing), encoding="utf-8") + source.write_bytes(b'#include "authored.hpp"\n#include "created.hpp"\nint original() { return 7; }\n') + assert update(tmp_path, monkeypatch, operation, [source]) + published = nodes(tmp_path) + assert published["authored"] == authored + assert published["created"]["_origin"] == "semantic" + # The previous import loses its only accepted source reference (INC35). + assert "missing" not in published + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_first_stamped_cpp_import_deletion_restoration_and_repeat(tmp_path, monkeypatch, operation): + """First-publication provenance remains eligible for exact prior-import orphan cleanup.""" + source = prepare(tmp_path, monkeypatch, "cpp") + original = source.read_bytes() + assert update(tmp_path, monkeypatch, operation) + first = payload(tmp_path) + source.unlink() + assert update(tmp_path, monkeypatch, operation, [source]) + assert "missing" not in nodes(tmp_path) + source.write_bytes(original) + assert update(tmp_path, monkeypatch, operation, [source]) + # Run metadata is transient, but all original node/edge facts must return. + assert payload(tmp_path)["nodes"] == first["nodes"] + assert payload(tmp_path)["links"] == first["links"] + graph = tmp_path / "graphify-out/graph.json" + restored, timestamp = pin_graph_mtime(graph) + assert update(tmp_path, monkeypatch, operation) + assert graph.read_bytes() == restored and graph.stat().st_mtime_ns == timestamp + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("language", ["cpp", "python"]) +def test_new_stub_replacement_failure_retains_cohort_cache_then_retries(tmp_path, monkeypatch, + operation, language): + """Real replacement failure cannot accept a newly stamped graph; retry commits once.""" + source = prepare(tmp_path, monkeypatch, language, imported=False) + # Qt refreshes may omit per-file AST cache admission; a real unchanged + # Python file supplies durable cache bytes rather than a vacuous snapshot. + (tmp_path / "unrelated.py").write_bytes(b"def untouched(): return 9\n") + assert update(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out" + graph = output / "graph.json" + pin_graph_mtime(graph) + before = {name: ((output / name).read_bytes(), (output / name).stat().st_mtime_ns) for name in PRODUCTS} + caches = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")} + assert caches and "missing" not in nodes(tmp_path) + corpus(tmp_path, language) + replace = watch.os_replace_with_fallback + + def reject_graph(staged, destination, **kwargs): + if os.fspath(destination).endswith("graph.json"): + raise OSError("simulated graph replacement failure") + return replace(staged, destination, **kwargs) + + with monkeypatch.context() as failure: + failure.setattr(watch, "os_replace_with_fallback", reject_graph) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + update(tmp_path, monkeypatch, operation, [source]) + assert rejected.value.code == 1 + else: + assert not update(tmp_path, monkeypatch, operation, [source]) + assert all((output / name).read_bytes() == data and (output / name).stat().st_mtime_ns == mtime + for name, (data, mtime) in before.items()) + assert all(path.exists() and path.read_bytes() == data for path, data in caches.items()) + assert not list(output.glob(".gfy-publish-*")) + assert update(tmp_path, monkeypatch, operation, [source]) + assert nodes(tmp_path)["missing"]["_origin"] == "semantic" + accepted, timestamp = pin_graph_mtime(graph) + assert update(tmp_path, monkeypatch, operation) + assert graph.read_bytes() == accepted and graph.stat().st_mtime_ns == timestamp diff --git a/tests/test_qt_qmake_adoption.py b/tests/test_qt_qmake_adoption.py new file mode 100644 index 0000000000..34dc75b364 --- /dev/null +++ b/tests/test_qt_qmake_adoption.py @@ -0,0 +1,156 @@ +"""REQ-QML-018-AC01: source-owned qmake paths never imply runtime roots.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qml_qmake import extract_qmake, parse_qmake +from graphify.security import sanitize_metadata + + +BASE = "QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.0\n" + + +def records(result, kind): + return [qml_metadata(node) for node in result["nodes"] + if qml_metadata(node).get("kind") == kind] + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +@pytest.mark.parametrize("bom", ["", "\ufeff"]) +def test_req_qml018_ac01_pwd_paths_preserve_current_file_and_original_token_spans(tmp_path, newline, bom): + """Quoted Unicode and continued paths retain literal text and byte boundaries.""" + directory = tmp_path / "app" + directory.mkdir() + source = bom + (BASE + 'SOURCES += $$PWD/service.cpp \\\n "$$PWD/Écran.cpp"\n' + 'QML_FILES += $$PWD/ui/Main.qml\n' + 'QML_IMPORT_PATH += $$PWD/ui/Public/Tools\n' + 'QMLPATHS += $$PWD/ui\n').replace("\n", newline) + raw = source.encode() + path = directory / "tools.pro" + path.write_bytes(raw) + result = extract_qmake(path, root=tmp_path) + assert not result.get("error") + paths = records(result, "qt_source") + records(result, "qt_import_path") + assert [md["value"] for md in paths] == [ + "service.cpp", "Écran.cpp", "ui/Main.qml", "ui/Public/Tools", "ui"] + for md in paths: + assert md["path_origin"] == "current_file_pwd" + span = md["span"] + lexeme = raw[span["start_byte"]:span["end_byte"]].decode() + assert lexeme.strip('"') == md["source_value"] + for point in ("start", "end"): + prefix = raw[:span[f"{point}_byte"]] + assert span[f"{point}_row"] == prefix.count(b"\n") + assert span[f"{point}_column"] == len(prefix) - prefix.rfind(b"\n") - 1 + imports = records(result, "qt_import_path") + assert [md["visibility"] for md in imports] == ["tooling", "build"] + assert [md["qmake_variable"] for md in imports] == ["QML_IMPORT_PATH", "QMLPATHS"] + transported = json.loads(json.dumps(result)) + for item in transported["nodes"] + transported["edges"]: + item["metadata"] = sanitize_metadata(item["metadata"]) + assert records(transported, "qt_source") == records(result, "qt_source") + assert result == parse_qmake(raw, relative_file="app/tools.pro") + + +def test_req_qml018_ac01_unrelated_conditional_build_settings_do_not_hide_module(): + """Ignored toolchain settings do not make unconditional membership uncertain.""" + source = BASE + """QT += qml quick +CONFIG += c++17 +win32: LIBS += $$PWD/vendor/lib.lib +unix { + QMAKE_CXXFLAGS += -Wall + contains(CONFIG, release) { + DEFINES += RELEASE_BUILD + } +} +QML_FILES += $$PWD/Main.qml +""" + result = parse_qmake(source) + assert not result.get("error") + assert records(result, "qt_module")[0]["uri"] == "Public.Tools" + assert records(result, "qt_source")[0]["value"] == "Main.qml" + assert records(result, "qt_qmake_statement") + + +@pytest.mark.parametrize("statement", [ + "win32: QML_FILES += $$PWD/Hidden.qml", + "unix {\n QML_FILES += $$PWD/Hidden.qml\n}", + "contains(CONFIG, quick): QML_IMPORT_NAME = Hidden", +]) +def test_req_qml018_ac01_relevant_conditions_remain_source_owned_uncertainty(statement): + """A relevant unevaluated branch is inspectable and never authoritative.""" + result = parse_qmake(BASE + "QML_FILES += $$PWD/Visible.qml\n" + statement) + assert result["partial"] and result["qml_failures"] + assert not records(result, "qt_module") + coverage = records(result, "qt_qmake_statement") + assert any(md.get("status") == "unresolved" and md.get("conditional") for md in coverage) + assert any(md.get("qmake_variable") == "QML_FILES" and md.get("status") == "observed" + for md in records(result, "qt_qmake_assignment")) + + +@pytest.mark.parametrize("value", [ + "$$OTHER/Main.qml", "$${PWD}/Main.qml", "$$PWDtail/Main.qml", + "$$PWD/$$files(*.qml)", "$$PWD/../../Outside.qml", "C:/Outside.qml", + "/Outside.qml", "$$PWD/../../Outside.qml", "$$PWD/../x/../../Outside.qml", +]) +def test_req_qml018_ac06_unsupported_or_escaping_paths_never_gain_authority(value): + """Only a lexical current-file prefix within the accepted root is admitted.""" + result = parse_qmake(BASE + f"QML_FILES += {value}\n", relative_file="app/tools.pro") + assert result["qml_failures"] + assert not records(result, "qt_module") + + +@pytest.mark.parametrize("statement", [ + "include(other.pri)", "load(qmltypes)", "system(echo no)", + "QML_FILES -= $$PWD/Main.qml", "QMLPATHS += $$unknown_path", "PWD = elsewhere", +]) +def test_req_qml018_ac06_unknown_mutations_and_pwd_overwrite_are_explicit(statement): + """Unknown mutation cannot be repaired by running a qmake hook or include.""" + result = parse_qmake(BASE + statement) + assert result["qml_failures"] + assert not records(result, "qt_module") + + +def test_req_qml018_ac01_standalone_import_roles_do_not_create_modules(): + """A normal application project may expose build/tool hints without a module.""" + result = parse_qmake("QT += qml quick\nQML_IMPORT_PATH += $$PWD/Public/Tools\n" + "QMLPATHS += $$PWD/imports\n", relative_file="app/project.pri") + assert not result.get("error") and not records(result, "qt_module") + imports = records(result, "qt_import_path") + assert [(md["value"], md["visibility"], md["module_key"]) for md in imports] == [ + ("Public/Tools", "tooling", None), ("imports", "build", None)] + + +def test_req_qml018_ac01_else_unrelated_scope_remains_non_authoritative(): + """Else scopes retain balance; no platform condition is selected.""" + result = parse_qmake(BASE + "win32 {\n LIBS += native.lib\n} else {\n LIBS += -lnative\n}\n") + assert not result.get("error") and records(result, "qt_module") + + +@pytest.mark.parametrize("value", ['"Main.qml"suffix', 'prefix"Main.qml"', '"Main.qml""Other.qml"']) +def test_req_qml018_ac06_quote_concatenation_is_not_invented_as_two_paths(value): + """Unsupported qmake token concatenation rejects the required source fact.""" + assert parse_qmake(BASE + f"QML_FILES += {value}\n")["qml_failures"] + + +def test_req_qml018_ac06_scope_depth_is_bounded(): + """Nested source scopes are refused before unbounded branch processing.""" + result = parse_qmake("win32 {\n" * 65 + BASE + "}\n" * 65) + assert result["diagnostics"][0]["reason"] == "qmake_scope_limit" + + +def test_req_qml018_ac01_message_text_does_not_mutate_named_metadata(): + """Build messages mentioning source variables are coverage, not assignments.""" + result = parse_qmake(BASE + 'message("QML_FILES = $$UNKNOWN")\n' + 'warning("include(other.pri)")\nQML_FILES += $$PWD/Main.qml\n') + assert not result.get("error") + assert [md["value"] for md in records(result, "qt_source")] == ["Main.qml"] + + +def test_req_qml018_ac06_multibyte_path_transport_limit_is_explicit(): + """UTF-8 transport bytes are bounded independently of character count.""" + result = parse_qmake(BASE + "QML_FILES += $$PWD/" + "é" * 200 + ".qml\n") + assert result["diagnostics"][0]["reason"] == "qmake_path_limit" diff --git a/tests/test_qt_qmake_adoption_integration.py b/tests/test_qt_qmake_adoption_integration.py new file mode 100644 index 0000000000..cc71cbefaf --- /dev/null +++ b/tests/test_qt_qmake_adoption_integration.py @@ -0,0 +1,193 @@ +"""REQ-QML-018-AC01/AC06: accepted qmake metadata through production consumers.""" +from __future__ import annotations + +import json +import stat + +import pytest + +import graphify.extract as extraction +from graphify.build import build_from_json +from graphify.cache import cache_dir +from graphify.export import to_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qml_qmake import extract_qmake +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qt_project_index import QtProjectIndex +from graphify.watch import _rebuild_code +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated + + +BUILD = """QT += qml quick +CONFIG += c++17 +win32: LIBS += $$PWD/optional.lib +QML_IMPORT_NAME = Public.Tools +QML_IMPORT_VERSION = 1.0 +HEADERS += $$PWD/backend.hpp +QML_FILES += $$PWD/Main.qml +QML_IMPORT_PATH += $$PWD/imports/Public/Hidden +QMLPATHS += $$PWD/imports +""" +HEADER = """class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service) + public: Q_INVOKABLE int fetch() { return 2; } +}; +""" +QML = "import Public.Tools 1.0\nService { property int result: fetch() }\n" +PRODUCTS = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root") + + +def corpus(root): + """A nested public application owns membership; hint-only module stays separate.""" + sources = {"app/tools.pro": BUILD, "app/backend.hpp": HEADER, "app/Main.qml": QML, + "app/imports/Public/Hidden/qmldir": "module Public.Hidden\nHidden 1.0 Hidden.qml\n", + "app/imports/Public/Hidden/Hidden.qml": "import QtQml\nQtObject {}\n", + "app/Consumer.qml": "import Public.Hidden 1.0\nHidden {}\n", + "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n"} + paths = [] + for name, text in sources.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(text.encode()) + paths.append(path) + return paths + + +def source_facts(graph, kind): + return {identity: qml_metadata(node) for identity, node in graph.nodes(data=True) + if qml_metadata(node).get("kind") == kind} + + +def test_req_qml018_ac01_direct_facade_project_index_and_reload_preserve_pwd_roles(tmp_path): + """Membership reaches accepted endpoints; tooling/build hints supply no lookup root.""" + paths = corpus(tmp_path) + direct = extract_qmake(tmp_path / "app/tools.pro", root=tmp_path) + result = extraction.extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not result["qml_failures"] and not result["failed_sources"] + qmake = {node["id"]: qml_metadata(node) for node in result["nodes"] + if node.get("source_file") == "app/tools.pro"} + direct_facts = {node["id"]: qml_metadata(node) for node in direct["nodes"]} + assert {identity: qmake[identity] for identity in direct_facts} == direct_facts + index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path) + assert index.module_context("app/backend.hpp").status == "resolved" + assert index.resolve_component("Public.Tools", "Main").status == "resolved" + assert index.resolve_component("Public.Hidden", "Hidden").status == "unavailable" + explicit = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path, + import_roots=("app/imports",)) + assert explicit.resolve_component("Public.Hidden", "Hidden").status == "resolved" + native = [edge for edge in result["edges"] if qt_metadata(edge).get("native_endpoint")] + assert len([edge for edge in native if edge["relation"] == "calls"]) == 1 + graph = build_from_json(result, root=tmp_path) + output = tmp_path / "accepted.json" + assert to_json(graph, {}, str(output), force=True) + restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert source_facts(graph, "qt_import_path") == source_facts(restored, "qt_import_path") + for md in source_facts(restored, "qt_import_path").values(): + assert md["visibility"] in {"tooling", "build"} + assert md["path_origin"] == "current_file_pwd" + from graphify.serve import _query_graph_text + answer = _query_graph_text(restored, "app/Main.qml", token_budget=8000) + assert "app/Main.qml" in answer and "app/tools.pro" in answer and "references" in answer + + +def test_req_qml018_ac01_build_pwd_paths_do_not_admit_disk_only_sources(tmp_path): + """A correct literal path cannot enlarge the explicitly accepted corpus.""" + paths = corpus(tmp_path) + accepted = [path for path in paths if path.name != "backend.hpp"] + result = extraction.extract(accepted, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not result["qml_failures"] + assert not any(qt_metadata(edge).get("native_endpoint") for edge in result["edges"]) + index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path) + assert index.module_context("app/backend.hpp").status == "unavailable" + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac01_metadata_only_pwd_edit_removal_matches_clean_updates(tmp_path, monkeypatch, operation): + """Only qmake bytes change; old membership and native edges retire faithfully.""" + corpus(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + cold = clean(tmp_path, tmp_path / ".cold") + assert normalized(clean(tmp_path, tmp_path / ".cold")) == normalized(cold) + initial = run(tmp_path, monkeypatch, operation) + assert normalized(initial) == normalized(cold) + prior = unrelated(initial) + path = tmp_path / "app/tools.pro" + path.write_bytes(BUILD.replace("QML_IMPORT_VERSION = 1.0", "QML_IMPORT_VERSION = 2.0").encode()) + edited = run(tmp_path, monkeypatch, operation, [path]) + assert unrelated(edited) == prior + assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited")) + assert not any(qt_metadata(data).get("native_endpoint") for _, _, data in edited.edges(data=True)) + path.write_bytes(BUILD.replace("HEADERS += $$PWD/backend.hpp\n", "").encode()) + removed = run(tmp_path, monkeypatch, operation, [path]) + assert unrelated(removed) == prior + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed")) + assert not any(qt_metadata(data).get("native_endpoint") for _, _, data in removed.edges(data=True)) + path.write_bytes(BUILD.encode()) + recovered = run(tmp_path, monkeypatch, operation, [path]) + assert normalized(recovered) == normalized(initial) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("mutation", [ + "unix: QML_FILES += $$PWD/Hidden.qml\n", "include(unaccepted.pri)\n", + "QML_FILES += $$PWD/../../Outside.qml\n", "QMLPATHS += $$factory()\n", +]) +def test_req_qml018_ac06_rejected_metadata_preserves_products_and_existing_cache(tmp_path, monkeypatch, capsys, operation, mutation): + """Actual metadata rejection leaves accepted disk products and cache entries intact.""" + corpus(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out" + prior = {name: (output / name).read_bytes() for name in PRODUCTS} + cache = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*") if path.is_file()} + assert cache + path = tmp_path / "app/tools.pro" + path.write_bytes((BUILD + mutation).encode()) + if operation == "manual": + with pytest.raises(SystemExit) as failure: + run(tmp_path, monkeypatch, operation, [path]) + assert failure.value.code == 1 + else: + assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True) + assert prior == {name: (output / name).read_bytes() for name in PRODUCTS} + assert all(path.exists() and path.read_bytes() == data for path, data in cache.items()) + assert "QML_GRAPH_PRESERVED" in capsys.readouterr().out + assert extract_qmake(path, root=tmp_path)["diagnostics"][0]["code"] == "QML_PROJECT_UNSUPPORTED" + path.write_bytes(BUILD.encode()) + repaired = run(tmp_path, monkeypatch, operation, [path]) + assert normalized(repaired) == normalized(initial) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac06_readonly_publication_after_metadata_edit_retains_and_retries(tmp_path, monkeypatch, operation): + """A real read-only graph destination rejects changed metadata publication.""" + import os + if os.name != "nt": + pytest.skip("Actual Windows read-only destination attribute") + corpus(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out" + prior = {name: (output / name).read_bytes() for name in PRODUCTS} + path = tmp_path / "app/tools.pro" + path.write_bytes(BUILD.replace("QML_IMPORT_VERSION = 1.0", "QML_IMPORT_VERSION = 2.0").encode()) + target = output / "graph.json" + target.chmod(stat.S_IREAD) + try: + if operation == "manual": + with pytest.raises(SystemExit) as failure: + run(tmp_path, monkeypatch, operation, [path]) + assert failure.value.code == 1 + else: + assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True) + assert prior == {name: (output / name).read_bytes() for name in PRODUCTS} + finally: + target.chmod(stat.S_IWRITE) + repaired = run(tmp_path, monkeypatch, operation, [path]) + assert normalized(repaired) != normalized(initial) + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".readonly-retry")) diff --git a/tests/test_qt_qml_bridge.py b/tests/test_qt_qml_bridge.py new file mode 100644 index 0000000000..7453a87e59 --- /dev/null +++ b/tests/test_qt_qml_bridge.py @@ -0,0 +1,161 @@ +"""Native QML providers require explicit source/module/member provenance.""" +from __future__ import annotations + +import copy +import json + +from graphify.build import build_from_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qt_qml_bridge import build_qt_qml_bridge +from graphify.export import to_json +from tests.qt_cpp_test_helpers import HEADER, collect, facts + + +def native_corpus(tmp_path, calls=None, header=HEADER): + calls = calls or 'void install(){ qmlRegisterType("Demo",1,0,"Backend"); }' + return collect(tmp_path, {"backend.hpp": header, "register.cpp": calls}) + + +def test_literal_provider_members_and_exact_endpoint_proof(tmp_path): + result = native_corpus(tmp_path) + before = copy.deepcopy(result) + index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path) + provider = index.module_type("Demo", 1, 0, "Backend", importer="Main.qml") + assert provider.status == "resolved" and index.is_provider(provider.target_id) + assert index.module_import("Demo", 1, 0).status == "resolved" + assert index.metadata(provider.target_id)["kind"] == "component" + for name, kind in (("next", "function"), ("valueChanged", "signal"), ("value", "property"), ("setValue", "function")): + member = index.member(provider.target_id, name) + assert member.status == "resolved" + target = next(node for node in result["nodes"] if node["id"] == member.target_id) + if kind != "property": + assert target.get("_callable") is True + assert not qt_metadata(target) + assert index.metadata(member.target_id)["kind"] == kind + proof = index.endpoint_proof(member.target_id, member.evidence) + assert proof["canonical_target_id"] == member.target_id + assert proof["class_id"] == qt_metadata(facts(result, "class", "Backend")[0])["class_id"] + assert proof["member_fact_id"] in member.evidence + assert index.endpoint_proof(member.target_id, ()) == {} + assert index.metadata(index.member(provider.target_id, "valueChanged").target_id)["parameter_names"] == ["value"] + assert index.member(provider.target_id, "ordinary").status == "unavailable" + assert index.member(provider.target_id, "hidden").status == "unavailable" + assert result == before + + +def test_module_namespace_multiple_types_not_ambiguous_duplicate_export_is(tmp_path): + calls = '''void install(){ +qmlRegisterType("Demo",1,0,"Backend"); +qmlRegisterType("Demo",1,0,"Another"); +qmlRegisterType("Demo",1,0,"Duplicate"); +qmlRegisterType("Demo",1,0,"Duplicate"); +}''' + result = native_corpus(tmp_path, calls) + index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path) + imported = index.module_import("Demo", 1, 0) + assert imported.status == "resolved" and len(imported.evidence) == 4 + duplicate = index.module_type("Demo", 1, 0, "Duplicate", importer="Main.qml") + assert duplicate.status == "ambiguous" and len(duplicate.candidates) == 2 + for name in ("Backend", "Another"): + assert index.module_type("Demo", 1, 0, name).status == "resolved" + assert index.endpoint_proof(imported.target_id, imported.evidence, kind="module")["kind"] == "module" + + +def test_overloads_ambiguous_before_generic_method_ids_collapse(tmp_path): + header = HEADER.replace("Q_INVOKABLE int next(int amount);", "Q_INVOKABLE int next(int amount);\n Q_INVOKABLE int next(QString value);") + result = native_corpus(tmp_path, header=header) + declarations = [qt_metadata(node) for node in facts(result, "member", "next")] + assert {md["signature"] for md in declarations} == {"next(int)", "next(QString)"} + assert len({md["generic_target_id"] for md in declarations}) == 1 + index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path) + provider = index.module_type("Demo", 1, 0, "Backend") + answer = index.member(provider.target_id, "next") + assert answer.status == "ambiguous" and answer.target_id is None + assert answer.reason == "native_member_or_overload_ambiguous" + + +def test_versions_namespaces_singleton_anonymous_and_no_label_fallback(tmp_path): + calls = '''void install(){ +qmlRegisterType("First",1,0,"Backend"); +qmlRegisterSingletonType("Second",2,3,"State",factory); +qmlRegisterAnonymousType("Second",2); +}''' + result = native_corpus(tmp_path, calls) + index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path) + assert index.module_type("Second", 2, 3, "Backend").status == "unavailable" + assert index.module_type("First", 2, 0, "Backend").status == "unavailable" + assert index.module_type("First", 1, 1, "Backend").reason == "native_module_version_unavailable" + state = index.module_type("Second", 2, 3, "State") + assert index.metadata(state.target_id)["singleton"] + assert index.module_type("Second", 2, 0, "").status == "unavailable" + assert index.module_type("Missing", None, None, "Backend").status == "unavailable" + + +def test_macro_provider_requires_real_source_membership_build_context(tmp_path): + from graphify.extractors.qml_cmake import extract_cmake + from graphify.qt_project_index import QtProjectIndex + result = collect(tmp_path, {"backend.hpp": HEADER}) + no_context = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path) + assert not no_context.provider_records + build_file = tmp_path / "CMakeLists.txt" + build_file.write_text('qt_add_qml_module(app URI Demo VERSION 1.2 SOURCES backend.hpp)\n') + metadata = extract_cmake(build_file, root=tmp_path) + nodes, edges = result["nodes"] + metadata["nodes"], result["edges"] + metadata["edges"] + project = QtProjectIndex(nodes, edges, root=tmp_path) + index = build_qt_qml_bridge(nodes, edges, root=tmp_path, project_index=project) + provider = index.module_type("Demo", 1, 2, "Backend") + assert provider.status == "resolved" and project.module_context("backend.hpp").target_id in provider.evidence + assert index.module_type("Demo", 1, 0, "Backend").status == "resolved" + assert index.module_type("Demo", 1, 1, "Backend").status == "resolved" + assert index.module_type("Demo", 1, 3, "Backend").status == "unavailable" + assert index.module_type("Other", 1, 2, "Backend").status == "unavailable" + + +def test_qmake_major_only_macro_registration_uses_documented_base_minor_zero(tmp_path): + from graphify.extractors.qml_qmake import extract_qmake + from graphify.qt_project_index import QtProjectIndex + result = collect(tmp_path, {"backend.hpp": HEADER}) + project_file = tmp_path / "app.pro" + project_file.write_text('CONFIG += qmltypes\nQML_IMPORT_NAME = Demo\nQML_IMPORT_MAJOR_VERSION = 1\nHEADERS += backend.hpp\n') + metadata = extract_qmake(project_file, root=tmp_path) + nodes, edges = result["nodes"] + metadata["nodes"], result["edges"] + metadata["edges"] + index = build_qt_qml_bridge(nodes, edges, root=tmp_path, + project_index=QtProjectIndex(nodes, edges, root=tmp_path)) + assert index.module_type("Demo", 1, 0, "Backend").status == "resolved" + assert index.module_type("Demo", 1, 1, "Backend").status == "unavailable" + + +def test_macro_introduction_and_removal_do_not_equal_module_maximum(tmp_path): + from graphify.extractors.qml_cmake import extract_cmake + from graphify.qt_project_index import QtProjectIndex + header = HEADER.replace("QML_NAMED_ELEMENT(Backend)", "QML_NAMED_ELEMENT(Backend) QML_ADDED_IN_VERSION(1,1) QML_REMOVED_IN_VERSION(1,2)") + result = collect(tmp_path, {"backend.hpp": header}) + project_file = tmp_path / "CMakeLists.txt" + project_file.write_text('qt_add_qml_module(app URI Demo VERSION 1.2 SOURCES backend.hpp)\n') + metadata = extract_cmake(project_file, root=tmp_path) + nodes, edges = result["nodes"] + metadata["nodes"], result["edges"] + metadata["edges"] + index = build_qt_qml_bridge(nodes, edges, root=tmp_path, + project_index=QtProjectIndex(nodes, edges, root=tmp_path)) + assert index.module_type("Demo", 1, 0, "Backend").status == "unavailable" + assert index.module_type("Demo", 1, 1, "Backend").status == "resolved" + assert index.module_type("Demo", 1, 2, "Backend").status == "unavailable" + + +def test_source_facts_build_and_json_reload_keep_members_spans_and_direction(tmp_path): + result = native_corpus(tmp_path) + graph = build_from_json(result, root=tmp_path) + output = tmp_path / "roundtrip.json" + assert to_json(graph, {}, str(output)) + restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + expected = {node["id"] for node in result["nodes"] if qt_metadata(node)} + assert expected.issubset(restored.nodes) + for nid in expected: + assert qt_metadata(dict(restored.nodes[nid]))["span"] == qt_metadata(next(node for node in result["nodes"] if node["id"] == nid))["span"] + for edge in result["edges"]: + if edge.get("context") != "qt_property_accessor": + continue + assert restored.has_edge(edge["source"], edge["target"]) + data = restored.edges[edge["source"], edge["target"]] + assert data["confidence"] == "EXTRACTED" and data["context"] == "qt_property_accessor" + assert (data["_src"], data["_tgt"]) == (edge["source"], edge["target"]) diff --git a/tests/test_qt_qml_export_consumers.py b/tests/test_qt_qml_export_consumers.py new file mode 100644 index 0000000000..e0d7841088 --- /dev/null +++ b/tests/test_qt_qml_export_consumers.py @@ -0,0 +1,88 @@ +"""Actual native bridge evidence survives supported machine-readable exports.""" +from __future__ import annotations + +import json + +import networkx as nx +import pytest + +from graphify.build import build_from_json +from graphify.export import to_graphml, to_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.serve import _shortest_path_text +from tests.test_qt_qml_integration import corpus + + +def bridge_edges(result): + return [edge for edge in result["edges"] if qt_metadata(edge).get("native_endpoint")] + + +def test_directed_graphml_roundtrip_keeps_native_evidence_and_literal_source_fields(tmp_path): + result = corpus(tmp_path) + graph = build_from_json(result, root=tmp_path, directed=True) + output = tmp_path / "native.graphml" + to_graphml(graph, {}, str(output)) + restored = nx.read_graphml(output) + assert restored.is_directed() + assert set(restored) == set(graph) + assert set(restored.edges) == set(graph.edges) + for node_id, original in graph.nodes(data=True): + if original.get("metadata"): + data = dict(restored.nodes[node_id]) + data["metadata"] = json.loads(data["metadata"]) + assert qml_metadata(data) == qml_metadata(original) + assert qt_metadata(data) == qt_metadata(original) + assert data["source_file"] == original["source_file"] + assert data["source_location"] == original["source_location"] + for edge in bridge_edges(result): + data = dict(restored.edges[edge["source"], edge["target"]]) + data["metadata"] = json.loads(data["metadata"]) + assert qt_metadata(data)["native_endpoint"] == qt_metadata(edge)["native_endpoint"] + assert (data["relation"], data["context"], data["confidence"]) == ( + edge["relation"], edge["context"], "INFERRED") + assert (data["source_file"], data["source_location"]) == (edge["source_file"], edge["source_location"]) + + +@pytest.mark.parametrize("directed", [False, True]) +def test_json_repeated_native_mechanisms_remain_distinct_and_directional(tmp_path, directed): + qml = '''import Demo 1.0 as Native +Native.Backend { + property int first: next(1) + property int second: next(2) + property int third: value + property int fourth: value + onValueChanged: { next(value); } +}''' + result = corpus(tmp_path, qml=qml) + expected = bridge_edges(result) + calls = [edge for edge in expected if edge["relation"] == "calls"] + assert len(calls) == 3 + assert len({edge["source"] for edge in calls}) == 3 + assert len({edge["target"] for edge in calls}) == 1 + graph = build_from_json(result, root=tmp_path, directed=directed) + output = tmp_path / "native.json" + assert to_json(graph, {}, str(output), force=True) + restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + for edge in expected: + data = restored.edges[edge["source"], edge["target"]] + assert (data.get("_src", edge["source"]), data.get("_tgt", edge["target"])) == (edge["source"], edge["target"]) + assert qt_metadata(data)["native_endpoint"] == qt_metadata(edge)["native_endpoint"] + assert data["relation"] == edge["relation"] and data["confidence"] == "INFERRED" + assert data["source_file"] == "Main.qml" and data["source_location"].startswith("L") + + +def test_native_path_reports_qml_call_site_and_cpp_declaration_without_reversing_flow(tmp_path): + result = corpus(tmp_path) + graph = build_from_json(result, root=tmp_path, directed=True) + call = next(edge for edge in bridge_edges(result) if edge["relation"] == "calls") + route = _shortest_path_text(graph, {"source": call["source"], "target": call["target"]}) + assert "Shortest path (1 hops)" in route and "--calls [INFERRED]-->" in route + assert f'Main.qml:{call["source_location"]}' in route + target = graph.nodes[call["target"]] + assert f'backend.hpp:{target["source_location"]}' in route + assert "qml_js_call" in route + assert "native_endpoint" not in route and "raw_values" not in route + reverse = _shortest_path_text(graph, {"source": call["target"], "target": call["source"]}) + assert "No directed path found" in reverse diff --git a/tests/test_qt_qml_integration.py b/tests/test_qt_qml_integration.py new file mode 100644 index 0000000000..0943d9cdbe --- /dev/null +++ b/tests/test_qt_qml_integration.py @@ -0,0 +1,119 @@ +"""Actual facade-to-graph native Qt/QML bridges and scoped rejection cases.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import extract +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.qt_cpp_test_helpers import HEADER, write + + +SOURCE = '''import Demo 1.0 as Native +Native.Backend { + id: root + property int doubled: value * 2 + property int called: next(1) + onValueChanged: { doubled = value; } +} +''' + + +def corpus(root, qml=SOURCE, header=HEADER, calls=None): + calls = calls or 'void install(){ qmlRegisterType("Demo",1,0,"Backend"); }' + paths = [write(root, "backend.hpp", header), write(root, "register.cpp", calls), write(root, "Main.qml", qml)] + return extract(paths, root=root, cache_root=root / "cache", parallel=False) + + +def sites(result, kind): + return [node for node in result["nodes"] if qml_metadata(node).get("kind") == kind] + + +@pytest.mark.parametrize("directed", [False, True]) +def test_actual_native_import_property_call_and_subscription_survive_roundtrip(tmp_path, directed): + result = corpus(tmp_path) + assert not result.get("qml_failures") + assert all(qml_metadata(node)["status"] == "resolved" for kind in ("import_resolution", "type_use", "handler", "call") + for node in sites(result, kind)) + reads = sites(result, "read") + assert len(reads) == 2 + assert {qml_metadata(node)["status"] for node in reads} == {"resolved", "dynamic"} + shadow = next(qml_metadata(node) for node in reads if qml_metadata(node)["status"] == "dynamic") + assert shadow["reference"] == "value" and shadow["reason"] == "javascript_lexical_binding" + edges = [edge for edge in result["edges"] if qt_metadata(edge).get("native_endpoint")] + assert {(edge["relation"], edge["context"]) for edge in edges} == { + ("imports", "qml_import_resolution"), ("uses", "qml_type_resolution"), + ("uses", "qml_binding_read"), ("calls", "qml_js_call"), ("references", "qml_signal_subscription")} + for edge in edges: + assert edge["confidence"] == "INFERRED" + proof = qt_metadata(edge)["native_endpoint"] + assert proof["canonical_target_id"] == edge["target"] + assert proof["provider_id"] in proof["evidence"] + graph = build_from_json(result, root=tmp_path, directed=directed) + output = tmp_path / "graph.json" + assert to_json(graph, {}, str(output)) + restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert restored.is_directed() == directed + for edge in edges: + assert graph.has_edge(edge["source"], edge["target"]) + assert restored.has_edge(edge["source"], edge["target"]) + data = restored.edges[edge["source"], edge["target"]] + assert (data["relation"], data["context"], data["confidence"]) == (edge["relation"], edge["context"], "INFERRED") + assert (data.get("_src", edge["source"]), data.get("_tgt", edge["target"])) == (edge["source"], edge["target"]) + assert qt_metadata(data)["native_endpoint"] == qt_metadata(edge)["native_endpoint"] + + +def test_duplicate_registration_has_no_arbitrary_type_or_member_bridge(tmp_path): + calls = 'void install(){ qmlRegisterType("Demo",1,0,"Backend"); qmlRegisterType("Demo",1,0,"Backend"); }' + result = corpus(tmp_path, calls=calls) + assert qml_metadata(sites(result, "type_use")[0])["status"] == "ambiguous" + assert all(qml_metadata(node)["status"] != "resolved" for kind in ("call", "handler") for node in sites(result, kind)) + assert not any(qt_metadata(edge).get("native_endpoint", {}).get("member_fact_id") for edge in result["edges"]) + + +def test_ambiguous_overload_and_version_revised_member_are_explicit(tmp_path): + header = HEADER.replace("Q_INVOKABLE int next(int amount);", "Q_INVOKABLE int next(int amount); Q_INVOKABLE int next(QString value);") + result = corpus(tmp_path, header=header) + assert qml_metadata(sites(result, "call")[0])["status"] == "ambiguous" + header = HEADER.replace("Q_INVOKABLE int next(int amount);", "Q_REVISION(1, 2) Q_INVOKABLE int next(int amount);") + result = corpus(tmp_path, header=header) + call = qml_metadata(sites(result, "call")[0]) + assert call["status"] == "unsupported" and call["reason"] == "native_member_revision_requires_supported_version" + header = HEADER.replace("NOTIFY valueChanged)", "NOTIFY valueChanged REVISION 2)") + result = corpus(tmp_path, header=header) + assert any(qml_metadata(node)["status"] == "unsupported" for node in sites(result, "read")) + + +@pytest.mark.parametrize("registration", [ + 'qmlRegisterUncreatableType("Demo",1,0,"Backend","reason")', + 'qmlRegisterSingletonType("Demo",1,0,"Backend",factory)', +]) +def test_noncreatable_native_object_declaration_does_not_get_type_edge(tmp_path, registration): + result = corpus(tmp_path, calls=f"void install(){{ {registration}; }}") + site = sites(result, "type_use")[0] + assert qml_metadata(site)["status"] == "unsupported" and qml_metadata(site)["reason"] == "native_type_not_creatable" + assert not any(edge["source"] == site["id"] and edge["relation"] == "uses" for edge in result["edges"]) + + +def test_registered_singleton_qualifier_member_uses_exact_source_type(tmp_path): + qml = 'import Demo 1.0 as Native\nQtObject { property int result: Native.State.next(1) }' + calls = 'void install(){ qmlRegisterSingletonType("Demo",1,0,"State",factory); }' + result = corpus(tmp_path, qml=qml, calls=calls) + call = sites(result, "call")[0] + assert qml_metadata(call)["status"] == "resolved" + target = qml_metadata(call)["resolved_target_id"] + assert next(node for node in result["nodes"] if node["id"] == target)["_callable"] + assert any(edge["source"] == call["id"] and edge["target"] == target and qt_metadata(edge).get("native_endpoint") for edge in result["edges"]) + + +def test_native_property_does_not_invent_automatic_qml_notify_signal(tmp_path): + header = HEADER.replace("NOTIFY valueChanged", "").replace("void valueChanged(int value);", "") + result = corpus(tmp_path, header=header) + handler = qml_metadata(sites(result, "handler")[0]) + assert handler["status"] != "resolved" + assert sites(result, "property_change_signal") == [] diff --git a/tests/test_qt_qml_projection.py b/tests/test_qt_qml_projection.py new file mode 100644 index 0000000000..4e1954b4e4 --- /dev/null +++ b/tests/test_qt_qml_projection.py @@ -0,0 +1,60 @@ +"""Typed bridge evidence survives the production builder and fails closed.""" +import copy + +import pytest + +from graphify.build import build_from_json +from graphify.extract import extract +from graphify.extractors.qt_cpp_facts import encode_qt, qt_metadata +from graphify.paths import load_node_link_graph +from graphify.export import to_json + + +def bridge(tmp_path): + cpp = tmp_path / "backend.cpp" + cpp.write_text('''class Backend : public QObject { + Q_OBJECT + public: Q_INVOKABLE int refresh() { return 1; } + signals: void ready(int value); + }; + void registerTypes() { qmlRegisterType("Demo.Tools", 1, 0, "Service"); } + ''', encoding="utf-8") + qml = tmp_path / "Main.qml" + qml.write_text('import Demo.Tools 1.0\nService { property int count: refresh(); onReady: count = 1 }', encoding="utf-8") + result = extract([cpp, qml], root=tmp_path, cache_root=tmp_path, parallel=False) + assert not result["qml_failures"] and not result["failed_sources"] + return result + + +def test_native_import_call_and_subscription_survive_default_graph_reload(tmp_path): + result = bridge(tmp_path) + graph = build_from_json(result, root=tmp_path) + calls = [edge for edge in result["edges"] if edge.get("relation") == "calls" and qt_metadata(edge).get("native_endpoint")] + assert len(calls) == 1 + endpoint = calls[0] + assert graph.has_edge(endpoint["source"], endpoint["target"]) + assert graph.edges[endpoint["source"], endpoint["target"]]["relation"] == "calls" + exported = tmp_path / "graph.json" + assert to_json(graph, {}, str(exported)) + loaded = load_node_link_graph(exported) + assert loaded.edges[endpoint["source"], endpoint["target"]]["_src"] == endpoint["source"] + assert loaded.edges[endpoint["source"], endpoint["target"]]["_tgt"] == endpoint["target"] + contexts = {data.get("context") for _, _, data in loaded.edges(data=True)} + assert {"qml_import_resolution", "qml_type_resolution", "qml_signal_subscription"} <= contexts + + +@pytest.mark.parametrize("damage", ["missing", "target", "class", "member", "provider"]) +def test_bridge_context_and_label_cannot_replace_exact_endpoint_proof(tmp_path, damage): + result = bridge(tmp_path) + edge = next(edge for edge in result["edges"] if edge.get("relation") == "calls" and qt_metadata(edge).get("native_endpoint")) + source, target = edge["source"], edge["target"] + values = qt_metadata(edge) + proof = copy.deepcopy(values["native_endpoint"]) + if damage == "missing": + edge["metadata"].pop("qt") + else: + field = {"target": "canonical_target_id", "class": "class_id", "member": "member_fact_id", "provider": "provider_id"}[damage] + proof[field] = source + edge["metadata"]["qt"] = encode_qt({**values, "native_endpoint": proof}) + graph = build_from_json(result, root=tmp_path) + assert not graph.has_edge(source, target) diff --git a/tests/test_qt_qml_search.py b/tests/test_qt_qml_search.py new file mode 100644 index 0000000000..1b70e6b580 --- /dev/null +++ b/tests/test_qt_qml_search.py @@ -0,0 +1,120 @@ +"""Semantic metadata participates in production scoring without identity leakage.""" +from __future__ import annotations + +import copy + +import networkx as nx +import pytest + +from graphify.extractors.qml_facts import encode_metadata +from graphify.extractors.qt_cpp_facts import encode_qt +from graphify.qt_qml_search import SEARCH_NAMESPACE, search_attributes +from graphify.serve import _node_attributes_text, _pick_scored_endpoint, _query_graph_text, _score_nodes + + +def node(): + return {"id": "opaque_node", "label": "declaration", "attributes": {"user": {"note": "kept"}}, + "metadata": {"qt": encode_qt({"kind": "member", "raw_name": "fetch", "raw_type": "QList", + "class_name": "Public::Backend", "signature": "fetch(int)", + "roles": ["invokable"], "uri": "Public.Tools", "status": "resolved", + "owner_id": "hidden_owner_93", "generic_target_id": "hidden_target_43"})}} + + +def test_decoded_names_types_roles_and_uris_are_indexed_without_mutating_facts(): + data = node() + before = copy.deepcopy(data) + attrs = search_attributes(data) + assert attrs["user"] == before["attributes"]["user"] + assert attrs[SEARCH_NAMESPACE]["qt"]["raw_type"] == "QList" + norm, tokens = _node_attributes_text(data) + assert all(value in norm for value in ("invokable", "public::backend", "fetch(int)", "public.tools", "qlist")) + assert "invokable" in tokens and "hidden_owner_93" not in norm and "hidden_target_43" not in norm + assert "raw_values" not in norm + assert data == before + graph = nx.Graph() + graph.add_node("native", **data) + graph.add_node("unrelated", label="other") + assert _score_nodes(graph, ["invokable"])[0][1] == "native" + + +def test_qml_import_value_is_semantic_but_arbitrary_literal_property_data_is_not(): + imported = {"metadata": {"qml": encode_metadata({"kind": "import", "value": "Public.Tools", "qualifier": "Tools"})}} + assert search_attributes(imported)[SEARCH_NAMESPACE]["qml"]["import_value"] == "Public.Tools" + prop = {"metadata": {"qml": encode_metadata({"kind": "property", "raw_name": "value", "literal_value": "private_text", "value": "private_text"})}} + assert "private_text" not in str(search_attributes(prop)) + + +@pytest.mark.parametrize("metadata", [None, "invalid", {"qt": []}, {"qt": {"contract_version": 2, "raw_name": "future"}}, + {"qt": {"contract_version": 1, "raw_values": {"raw_name": "!!!"}}}]) +def test_missing_future_or_corrupted_metadata_preserves_existing_search(metadata): + data = {"attributes": {"user": "existing"}, "metadata": metadata} + assert search_attributes(data) == {"user": "existing"} + assert "existing" in _node_attributes_text(data)[0] + + +def test_reserved_attribute_collision_wins_without_overwrite(): + data = node() + data["attributes"][SEARCH_NAMESPACE] = {"custom": "kept"} + before = copy.deepcopy(data) + assert search_attributes(data) == data["attributes"] + assert data == before + + +def test_metadata_text_and_list_work_are_bounded(): + data = {"metadata": {"qt": {"contract_version": 1, "kind": "member", "raw_name": "x" * 10_000, + "roles": ["x"] * 10_000, "uri": "Public.Tools"}}} + view = search_attributes(data)[SEARCH_NAMESPACE]["qt"] + assert "raw_name" not in view and "roles" not in view and view["uri"] == "Public.Tools" + assert len(str(view)) < 100 + + +@pytest.mark.parametrize("name", ["qt", "qml"]) +def test_deep_malformed_literal_transport_cannot_crash_production_search(name): + nested = "not encoded text" + for _ in range(3000): + nested = [nested] + data = {"label": "safe", "attributes": {"user": "existing searchable text"}, + "metadata": {name: {"contract_version": 1, "kind": "member", "raw_name": "safe", + "raw_values": {"raw_name": nested}}}} + assert search_attributes(data) == data["attributes"] + assert "existing searchable text" in _node_attributes_text(data)[0] + graph = nx.Graph() + graph.add_node("safe", **data) + assert "NODE safe" in _query_graph_text(graph, "existing", depth=1) + assert data["metadata"][name]["raw_values"]["raw_name"] is nested + + +def test_public_event_semantics_are_searchable_without_indexing_supplied_data(): + data = {"metadata": {"qt": encode_qt({"kind": "connect", "declared_type": "QueuedConnection", + "flags": ["UniqueConnection"], "callable_form": "member_pointer", "operation": "connect", + "bridge_direction": "cpp_to_qml", "literal_value": "unrelated_private_literal"})}} + text = _node_attributes_text(data)[0] + assert all(value in text for value in ("queuedconnection", "uniqueconnection", "member_pointer", "cpp_to_qml")) + assert "unrelated_private_literal" not in text + + +def test_exact_endpoint_identity_wins_over_fuzzy_token_collision(): + graph = nx.Graph() + graph.add_node("qml:Main.qml:component:aaa", label="Main") + graph.add_node("qml:Main.qml:property:bbb", label="input") + scored = [(1000.0, "qml:Main.qml:component:aaa"), (1.0, "qml:Main.qml:property:bbb")] + assert _pick_scored_endpoint(graph, scored, "qml:Main.qml:property:bbb") == "qml:Main.qml:property:bbb" + + +def test_fuzzy_endpoint_still_prefers_full_token_coverage(): + graph = nx.Graph() + graph.add_node("summary", label="Rejection Summary") + graph.add_node("judge", label="Degenerate Reject-Everything Judge") + assert _pick_scored_endpoint(graph, [(1000, "summary"), (1, "judge")], "Reject-everything judge") == "judge" + + +def test_query_edge_rendering_is_identical_for_reversed_actual_graph_insertion(tmp_path): + from graphify.build import build_from_json + from tests.test_qt_qml_integration import corpus + original = build_from_json(corpus(tmp_path), root=tmp_path) + reversed_graph = nx.Graph() + reversed_graph.add_nodes_from(reversed(list(original.nodes(data=True)))) + reversed_graph.add_edges_from(reversed(list(original.edges(data=True)))) + query = _query_graph_text(original, "next", depth=2) + assert query == _query_graph_text(reversed_graph, "next", depth=2) + assert "EDGE" in query and "qml_js_call" in query and "Main.qml:L" in query diff --git a/tests/test_qt_query_consumers.py b/tests/test_qt_query_consumers.py new file mode 100644 index 0000000000..ef85e4a22b --- /dev/null +++ b/tests/test_qt_query_consumers.py @@ -0,0 +1,32 @@ +"""QML-013-AC01 exact scope and metadata through actual query/explain/path CLI.""" +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.serve import _load_graph, _node_attributes_text, _shortest_path_text +from graphify.qt_qml_search import search_attributes +from tests.qt_analysis_helpers import analysis, sites +from tests.qt_consumer_helpers import cli, saved_graph +from tests.test_qt_signals_slots import SOURCE + + +def test_cli_query_explain_and_path_use_source_scoped_nodes(tmp_path, monkeypatch, capsys): + path, result, component, prop, read = saved_graph(tmp_path) + query = cli(monkeypatch, capsys, path, "query", read["id"]) + assert "Main.qml" in query and "INFERRED" in query + explanation = cli(monkeypatch, capsys, path, "explain", prop["id"]) + assert "Main.qml" in explanation and prop["id"] in explanation + route = cli(monkeypatch, capsys, path, "path", component["id"], prop["id"]) + assert "No path" not in route and "input" in route + loaded = _load_graph(str(path)) + text = _shortest_path_text(loaded, {"source": component["id"], "target": prop["id"]}) + assert "input" in text and "Main.qml" in text + other_ids = {node["id"] for node in result["nodes"] if node["source_file"] == "Other.qml"} + assert not other_ids & {target for _, target in loaded.out_edges(read["id"])} + + +def test_public_connection_type_is_searchable_without_opaque_transport(tmp_path): + result = analysis(tmp_path, {"events.cpp": SOURCE}) + connection = sites(result, "connect")[0] + assert qt_metadata(connection)["declared_type"] == "QueuedConnection" + text, _ = _node_attributes_text(connection) + assert "queuedconnection" in text + attributes = search_attributes(connection) + assert "raw_values" not in str(attributes) and "owner_scope_key" not in str(attributes) diff --git a/tests/test_qt_readonly_publication.py b/tests/test_qt_readonly_publication.py new file mode 100644 index 0000000000..2c41753922 --- /dev/null +++ b/tests/test_qt_readonly_publication.py @@ -0,0 +1,74 @@ +"""REQ-QML-018-AC06: real Windows rejection through manual/watch publication.""" +from __future__ import annotations + +import os +from pathlib import Path +import stat + +import pytest + +import graphify.watch as watch +from graphify.paths import write_text_atomic +from tests.test_qt_final_incremental_parity import clean, normalized, project, run + + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute") + + +def durable(root): + """Only accepted products/cache entries count; candidate scratch is not accepted.""" + output = root / "graphify-out" + files = [output / name for name in + ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")] + files.extend(path for path in (output / "cache").rglob("*") if path.is_file()) + return {path.relative_to(output).as_posix(): path.read_bytes() for path in files} + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac06_readonly_graph_preserves_products_then_retries(tmp_path, monkeypatch, operation): + """The real OS rejects the candidate after successful source analysis; retry agrees.""" + source = project(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + accepted = durable(tmp_path) + source.write_text(source.read_text(encoding="utf-8").replace("adjustment", "offset"), + encoding="utf-8", newline="") + target = tmp_path / "graphify-out/graph.json" + target.chmod(stat.S_IREAD) + try: + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, monkeypatch, operation, [source]) + assert rejected.value.code == 1 + else: + assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + assert durable(tmp_path) == accepted + assert not target.stat().st_mode & stat.S_IWRITE + assert not list(target.parent.glob(".gfy-*.tmp")) + finally: + target.chmod(stat.S_IWRITE) + repaired = run(tmp_path, monkeypatch, operation, [source]) + assert normalized(repaired) != normalized(initial) + assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired")) + current = durable(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(repaired) + assert durable(tmp_path) == current + + +@pytest.mark.parametrize("name", ["manifest.json", ".qt_analysis.json", ".graphify_root", "cache/entry.json"]) +def test_req_qml018_ac06_individual_readonly_product_writer_preserves_prior(tmp_path, name): + """Every product's shared writer rejects without changing that accepted destination.""" + target = tmp_path / "graphify-out" / name + target.parent.mkdir(parents=True) + target.write_bytes(b"accepted\r\n") + target.chmod(stat.S_IREAD) + try: + with pytest.raises(PermissionError): + write_text_atomic(target, "candidate\n") + assert target.read_bytes() == b"accepted\r\n" + assert {path for path in target.parent.iterdir()} == {target} + finally: + target.chmod(stat.S_IWRITE) + write_text_atomic(target, "candidate\n") + assert target.read_text(encoding="utf-8") == "candidate\n" diff --git a/tests/test_qt_receiver_boundaries.py b/tests/test_qt_receiver_boundaries.py new file mode 100644 index 0000000000..9498873ea7 --- /dev/null +++ b/tests/test_qt_receiver_boundaries.py @@ -0,0 +1,224 @@ +"""REQ-QML-017-AC02/AC04: persisted reflection belongs to its receiving QObject.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qml_facts import encode_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qml_resolution_types import qml_metadata +from graphify.qt_qml_access_index import QtQmlAccessIndex +from tests.qt_analysis_helpers import analysis, sites + +QML = '''import QtQuick +Item { + id: owner + objectName: "owner" + property int rootOnly: 7 + function rootAction() {} + Item { id: left; objectName: "left"; property int localValue: 1; + Item { objectName: "deep"; property int count: 2 } } + Item { id: right; objectName: "right"; property int localValue: 2 } +} +''' +TARGET_CONTEXTS = {"qt_cpp_qml_property_read", "qt_cpp_qml_property_write", "qt_cpp_qml_invoke", "qt_cpp_qml_find_child"} + + +def source(root, tail): + return f'''// original-byte Unicode evidence: café +void use() {{ QQuickView view; + view.setSource(QUrl("{(root / 'Main.qml').as_uri()}")); + auto root = view.rootObject(); auto left = root->findChild("left"); + {tail} +}}''' + + +def persisted(root, tail, qml=QML, *, extra=None, directed=False): + cpp = source(root, tail).replace("\n", "\r\n") + result = analysis(root, {"access.cpp": cpp, "Main.qml": qml, **(extra or {})}) + graph = build_from_json(result, root=root, directed=directed) + output = root / "proof.json" + assert to_json(graph, {}, str(output), force=True) + payload = json.loads(output.read_text(encoding="utf-8")) + reloaded = load_node_link_graph(payload) + site = sites(result, "qml_access")[-1] + md = qt_metadata(site) + assert (root / "access.cpp").read_bytes()[md["span"]["start_byte"]:md["span"]["end_byte"]].decode() == tail.split(";")[-2].strip() + links = [edge for edge in payload["links"] if edge["source"] == site["id"] and edge.get("context") in TARGET_CONTEXTS] + assert all(reloaded.has_edge(edge["source"], edge["target"]) for edge in links) + for edge in links: + attributes = reloaded.edges[edge["source"], edge["target"]] + assert attributes["source_file"] == "access.cpp" and attributes["source_location"] == site["source_location"] + assert qt_metadata(attributes)["span"] == md["span"] + assert attributes["relation"] == edge["relation"] and attributes["confidence"] == "INFERRED" + if not directed: + assert (attributes["_src"], attributes["_tgt"]) == (edge["source"], edge["target"]) + return result, site, links + + +@pytest.mark.parametrize("directed", [False, True]) +@pytest.mark.parametrize("tail", ['left->property("rootOnly");', 'left->setProperty("rootOnly", 9);', + 'QMetaObject::invokeMethod(left, "rootAction");']) +def test_req_qml017_ac02_child_reflection_cannot_use_lexical_root(tmp_path, directed, tail): + _, site, links = persisted(tmp_path, tail, directed=directed) + assert not links and qt_metadata(site)["status"] != "resolved" + + +@pytest.mark.parametrize("tail", ['left->findChild("right");', + 'root->findChild("deep", Qt::FindDirectChildrenOnly);', + 'root->findChild("owner");']) +def test_req_qml017_ac02_findchild_rejects_siblings_depth_and_receiver(tmp_path, tail): + _, site, links = persisted(tmp_path, tail) + assert not links and qt_metadata(site)["status"] != "resolved" + + +@pytest.mark.parametrize("tail, expected", [ + ('left->property("localValue");', "localValue"), ('root->property("rootOnly");', "rootOnly"), + ('QMetaObject::invokeMethod(root, "rootAction");', "rootAction"), + ('left->findChild("deep");', "Item"), + ('root->findChild("left", Qt::FindDirectChildrenOnly);', "left"), + ('root->findChild("deep", Qt::FindChildrenRecursively);', "Item"), +]) +def test_req_qml017_ac04_owned_members_and_subtree_targets_survive_reload(tmp_path, tail, expected): + result, site, links = persisted(tmp_path, tail) + assert qt_metadata(site)["status"] == "resolved" and len(links) == 1 + target = next(node for node in result["nodes"] if node["id"] == links[0]["target"]) + assert qml_metadata(target)["raw_name"] == expected + + +def test_req_qml017_ac02_property_held_qobject_has_construction_owner(tmp_path): + qml = 'import QtQml\nQtObject { property QtObject child: QtObject { objectName: "details"; property int count: 1 } }' + result, site, links = persisted(tmp_path, 'auto child = root->findChild("details"); child->setProperty("count", 2);', qml) + assert qt_metadata(site)["status"] == "resolved" and len(links) == 1 + child = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "object" and qml_metadata(node).get("parent_scope_key")) + md = qml_metadata(child) + assert md["construction_parent_kind"] == "property" and md["construction_parent_scope_key"] == md["parent_scope_key"] + assert "static_parent_scope_key" not in md + + +@pytest.mark.parametrize("tail, expected", [ + ('root->property("status");', "status"), + ('QMetaObject::invokeMethod(root, "refresh");', "refresh"), + ('auto child = root->findChild("details"); child->property("count");', "count"), +]) +def test_req_qml017_ac04_native_base_members_and_property_child_keep_source_proof(tmp_path, tail, expected): + qml = ('import QtQml\nimport Public.Tools 1.0\nService { ' + 'property QtObject child: QtObject { objectName: "details"; property int count: 1 } }') + extra = {"backend.h": 'class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service) ' + 'Q_PROPERTY(int status READ status) public: int status() { return 1; } ' + 'Q_INVOKABLE void refresh() {} };', + "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h)'} + result, site, links = persisted(tmp_path, tail, qml, extra=extra) + assert qt_metadata(site)["status"] == "resolved" and len(links) == 1 + target = next(node for node in result["nodes"] if node["id"] == links[0]["target"]) + if expected == "count": + assert qml_metadata(target)["raw_name"] == expected + elif expected == "status": + assert qt_metadata(target)["raw_name"] == expected and target["source_file"] == "backend.h" + else: + member = next(node for node in result["nodes"] if qt_metadata(node).get("generic_target_id") == target["id"] + and qt_metadata(node).get("kind") == "member") + assert qt_metadata(member)["raw_name"] == expected and target["source_file"] == "backend.h" + assert links[0]["relation"] == "calls" + + +@pytest.mark.parametrize("annotation", ["QML_UNCREATABLE(\"source-only\")", "QML_SINGLETON", "Q_GADGET"]) +def test_req_qml017_ac02_noncreatable_singleton_and_gadget_cannot_prove_qobject_creation(tmp_path, annotation): + qml = ('import QtQml\nimport Public.Tools 1.0\nService { ' + 'property QtObject child: QtObject { objectName: "details" } }') + base = "" if annotation == "Q_GADGET" else " : public QObject" + extra = {"backend.h": 'class Backend' + base + ' { QML_NAMED_ELEMENT(Service) ' + + (annotation if annotation == "Q_GADGET" else "Q_OBJECT " + annotation) + ' };', + "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h)'} + _, site, links = persisted(tmp_path, 'root->findChild("details");', qml, extra=extra) + assert not links and qt_metadata(site)["status"] != "resolved" + + +@pytest.mark.parametrize("write", [False, True]) +def test_req_qml017_ac02_inherited_member_is_receiver_owned_and_readonly_write_rejected(tmp_path, write): + qml = 'import QtQuick\nItem { property int rootOnly: 9; Base { objectName: "left" } }' + extra = {"Base.qml": 'import QtQuick\nItem { readonly property int inherited: 3; function action() {} }'} + tail = 'left->setProperty("inherited", 4);' if write else 'left->property("inherited");' + result, site, links = persisted(tmp_path, tail, qml, extra=extra) + if write: + assert not links and qt_metadata(site)["reason"] == "readonly_property" + else: + assert len(links) == 1 and qt_metadata(site)["status"] == "resolved" + assert next(node for node in result["nodes"] if node["id"] == links[0]["target"])["source_file"] == "Base.qml" + + +def test_req_qml017_ac02_duplicate_names_are_ambiguous_only_inside_receiver(tmp_path): + qml = QML.replace('objectName: "deep";', 'objectName: "right";') + _, site, links = persisted(tmp_path, 'root->findChild("right");', qml) + assert not links and qt_metadata(site)["status"] == "ambiguous" + _, site, links = persisted(tmp_path, 'left->findChild("right");', qml) + assert len(links) == 1 and qt_metadata(site)["status"] == "resolved" + + +@pytest.mark.parametrize("mutation", ['parent: right;', 'Component.onCompleted: left.parent = right;', + 'function move() { left.parent = right; }', + 'function move(key) { left[key] = right; }']) +def test_req_qml017_ac02_parent_bindings_and_js_writes_do_not_authorize_construction_tree(tmp_path, mutation): + qml = QML.replace('objectName: "deep";', f'objectName: "deep"; {mutation}') + result, site, links = persisted(tmp_path, 'root->findChild("deep");', qml) + assert not links and qt_metadata(site)["reason"] == "component_parenting_mutated" + evidence = [qml_metadata(node)["construction_parent_mutation_spans"] for node in result["nodes"] + if qml_metadata(node).get("construction_parent_dynamic")] + assert evidence and all(spans == evidence[0] for spans in evidence) + span = evidence[0][0] + assert "parent" in (tmp_path / "Main.qml").read_bytes()[span["start_byte"]:span["end_byte"]].decode() or "[key]" in mutation + + +@pytest.mark.parametrize("fragment", ['Unknown { objectName: "blocked" }', + 'Component { Item { objectName: "blocked" } }', + 'Loader { sourceComponent: Item { objectName: "blocked" } }']) +def test_req_qml017_ac02_unknown_types_and_component_templates_remain_unavailable(tmp_path, fragment): + qml = 'import QtQuick\nItem { ' + fragment + ' }' + _, site, links = persisted(tmp_path, 'root->findChild("blocked");', qml) + assert not links and qt_metadata(site)["status"] != "resolved" + + +@pytest.mark.parametrize("corrupt", ["scope", "owner", "source", "confidence", "origin", "parent_origin", "owner_span"]) +def test_req_qml017_ac02_construction_lookup_requires_accepted_declaration_proof(tmp_path, corrupt): + result = analysis(tmp_path, {"Main.qml": QML}) + nodes, edges = copy.deepcopy(result["nodes"]), copy.deepcopy(result["edges"]) + child = next(node for node in nodes if qml_metadata(node).get("object_id") == "left") + root = next(node for node in nodes if qml_metadata(node).get("object_id") == "owner") + declaration = next(edge for edge in edges if edge["target"] == child["id"] and qml_metadata(edge).get("kind") == "declaration") + if corrupt == "scope": + child["metadata"]["qml"] = encode_metadata({**qml_metadata(child), "construction_parent_scope_key": "absent"}) + elif corrupt == "origin": + child["_origin"] = "llm" + elif corrupt == "parent_origin": + root["_origin"] = "llm" + elif corrupt == "owner_span": + root["metadata"]["qml"] = encode_metadata({**qml_metadata(root), "span": qml_metadata(child)["span"]}) + else: + declaration[{"owner": "source", "source": "source_file", "confidence": "confidence"}[corrupt]] = "unaccepted" + before = copy.deepcopy((nodes, edges)) + index = QtQmlAccessIndex(nodes, edges, root=tmp_path) + resolution = index.find_child(root["id"], "left") + assert resolution.target_id is None + assert (nodes, edges) == before + + +def test_req_qml017_ac02_proved_cpp_parent_mutation_keeps_source_but_no_target(tmp_path): + _, site, links = persisted(tmp_path, 'left->setParent(other); root->findChild("deep");') + assert not links and qt_metadata(site)["status"] != "resolved" + + +def test_req_qml017_ac02_unsupported_findchild_options_cannot_choose_target(tmp_path): + _, site, links = persisted(tmp_path, 'root->findChild("left", options);') + assert not links and qt_metadata(site)["reason"] == "find_child_options_unsupported" + + +@pytest.mark.parametrize("target_type", ["QQuickItem*", "Custom*", "QObject"]) +def test_req_qml017_ac02_unproved_findchild_type_filter_cannot_choose_object(tmp_path, target_type): + """A QObject construction tree does not prove an arbitrary requested cast.""" + _, site, links = persisted(tmp_path, f'root->findChild<{target_type}>("left");') + assert not links and qt_metadata(site)["reason"] == "find_child_type_unsupported" diff --git a/tests/test_qt_reflection_type_aliases.py b/tests/test_qt_reflection_type_aliases.py new file mode 100644 index 0000000000..5b6d3c9539 --- /dev/null +++ b/tests/test_qt_reflection_type_aliases.py @@ -0,0 +1,26 @@ +"""REQ-QML-017: a supported findChild filter needs SDK QObject identity.""" +from __future__ import annotations + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.test_qt_receiver_boundaries import persisted, source + + +@pytest.mark.parametrize("declaration,filter_type,resolved", [ + ("", "QObject", True), + ("", "::QObject", True), + ("class Other {}; using QObject = Other;", "QObject", False), + ("class Other {}; typedef Other QObject;", "QObject", False), + ("class QObject {};", "QObject", False), +]) +def test_req_qml017_ac02_findchild_filter_rejects_shadowed_sdk_type(tmp_path, declaration, filter_type, resolved): + """Original pointer spelling cannot authorize a child cast to another class.""" + tail = f'root->findChild<{filter_type}*>("child");' + result, site, links = persisted(tmp_path, tail, + 'import QtQuick\nItem { Item { objectName: "child" } }', + extra={"access.cpp": declaration + "\n" + source(tmp_path, tail)}) + assert bool(links) is resolved + assert qt_metadata(site)["status"] == ("resolved" if resolved else "unsupported") + if not resolved: + assert qt_metadata(site)["reason"] == "find_child_type_unsupported" diff --git a/tests/test_qt_reflection_type_identity.py b/tests/test_qt_reflection_type_identity.py new file mode 100644 index 0000000000..4bb3571777 --- /dev/null +++ b/tests/test_qt_reflection_type_identity.py @@ -0,0 +1,204 @@ +"""REQ-QML-017-AC02/AC04: SDK reflection authority and persisted receiver targets. + +Public synthetic inputs reach extraction, build, JSON publication and reload. No +Qt compiler, application, QML component or corpus build hook executes. The source +class controls describe accepted parser inputs, not claims of successful Qt builds. +""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qml_resolution_types import qml_metadata +from tests.qt_analysis_helpers import analysis, sites + +QML = 'import QtQml\nQtObject { property int count: 1; function refresh() {} }' +OPERATIONS = { + "invoke": ('static void invokeMethod(QObject *, const char *) {}', + 'QMetaObject::invokeMethod(root, "refresh");', "QMetaObject"), + "static_read": ('static QVariant read(QObject *, const char *) {}', + 'QQmlProperty::read(root, "count");', "QQmlProperty"), + "static_write": ('static bool write(QObject *, const char *, QVariant) {}', + 'QQmlProperty::write(root, "count", 2);', "QQmlProperty"), + "property_handle": ('OtherProperty(QObject *, const char *) {} QVariant read() {}', + 'QQmlProperty handle(root, "count"); handle.read();', "QQmlProperty"), +} +TARGET_CONTEXTS = {"qt_cpp_qml_invoke", "qt_cpp_qml_property_read", "qt_cpp_qml_property_write"} + + +def source(root, operation, shadow, *, absolute=False): + """A real source loader supplies the receiver; the reflection type varies.""" + member, tail, sdk_name = OPERATIONS[operation] + definition, alias, namespace = "", "", "" + if shadow == "local_alias": + other = "OtherMeta" if sdk_name == "QMetaObject" else "OtherProperty" + definition = f"struct {other} {{ {member} }};" + alias = f"using {sdk_name} = {other};" + elif shadow in {"global_source_class", "namespace_source_class"}: + definition = f"struct {sdk_name} {{ {member.replace('OtherProperty(', 'QQmlProperty(')} }};" + namespace = "Public" if shadow == "namespace_source_class" else "" + elif shadow == "sdk_namespace_control": + namespace = "Public" + elif shadow == "forward_source_class": + definition = f"class {sdk_name};" + elif shadow == "conditional_alias": + definition = "struct Other {};" + alias = f"#if FEATURE\nusing {sdk_name} = Other;\n#endif\n" + elif shadow == "sdk_alias_control": + alias = f"using {sdk_name} = ::{sdk_name};" + elif shadow != "sdk_control": + raise ValueError(shadow) + if absolute: + tail = tail.replace(sdk_name, "::" + sdk_name, 1) + body = f'''// Original-byte evidence: café +{definition} +void use() {{ QQuickView view; + view.setSource(QUrl("{(root / 'Main.qml').as_uri()}")); + auto root = view.rootObject(); + {alias} + {tail} +}}''' + return "\ufeff" + (("namespace " + namespace + " {\n" + body + "\n}") if namespace else body).replace("\n", "\r\n") + + +def persisted(root, operation, shadow, *, directed=False, absolute=False): + """Check actual durable direction and original source evidence before rejection.""" + original = source(root, operation, shadow, absolute=absolute) + result = analysis(root, {"access.cpp": original, "Main.qml": QML}) + graph = build_from_json(result, root=root, directed=directed) + output = root / "proof.json" + assert to_json(graph, {}, str(output), force=True) + payload = json.loads(output.read_text(encoding="utf-8")) + restored = load_node_link_graph(payload) + accesses = sites(result, "qml_access") + by_id = {node["id"]: node for node in accesses} + for node in accesses: + metadata = qt_metadata(node) + span = metadata["span"] + text = original.encode()[span["start_byte"]:span["end_byte"]].decode() + assert text and text in original and "\r" not in text + assert node["source_location"].split("-", 1)[0] == "L" + str(original.encode()[:span["start_byte"]].count(b"\n") + 1) + assert node["source_file"] == "access.cpp" + assert node["source_location"].startswith("L") + links = [edge for edge in payload["links"] if edge["source"] in by_id + and edge.get("context") in TARGET_CONTEXTS] + for edge in links: + site = by_id[edge["source"]] + assert restored.has_edge(edge["source"], edge["target"]) + actual = restored.edges[edge["source"], edge["target"]] + if directed: + assert not restored.has_edge(edge["target"], edge["source"]) + else: + assert (actual["_src"], actual["_tgt"]) == (edge["source"], edge["target"]) + assert actual["relation"] == ("calls" if operation == "invoke" else "uses") + assert actual["confidence"] == "INFERRED" + assert actual["source_file"] == "access.cpp" + assert actual["source_location"] == site["source_location"] + assert qt_metadata(actual)["span"] == qt_metadata(site)["span"] + return result, accesses, links, restored, output + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +@pytest.mark.parametrize("shadow", ["local_alias", "global_source_class", "namespace_source_class", "forward_source_class", "conditional_alias"]) +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml017_ac02_shadowed_reflection_type_cannot_create_qml_target(tmp_path, operation, shadow, directed): + """A local alias or source class spelling is not the Qt reflection SDK class.""" + _, accesses, links, _, _ = persisted(tmp_path, operation, shadow, directed=directed) + assert not links, "Unrelated reflection class persisted a false QML member target" + rejected = [qt_metadata(node) for node in accesses if qt_metadata(node).get("reflection_supported") is False] + assert len(rejected) == 1 + assert rejected[0]["status"] == "unsupported" and rejected[0]["reason"] == "reflection_api_type_unestablished" + assert all(qt_metadata(node)["status"] != "resolved" and not qt_metadata(node).get("target_id") for node in accesses) + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +@pytest.mark.parametrize("shadow, absolute", [("sdk_control", False), ("sdk_namespace_control", False), + ("local_alias", True), ("sdk_alias_control", False)]) +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml017_ac04_unshadowed_sdk_reflection_preserves_targets_and_provenance(tmp_path, operation, shadow, absolute, directed): + """The correction must preserve genuine SDK calls, including namespace scope.""" + result, accesses, links, _, _ = persisted(tmp_path, operation, shadow, absolute=absolute, directed=directed) + assert len(links) == (2 if operation == "property_handle" else 1) + assert accesses and all(qt_metadata(node)["status"] == "resolved" for node in accesses) + nodes = {node["id"]: node for node in result["nodes"]} + assert {qml_metadata(nodes[edge["target"]])["raw_name"] for edge in links} == { + "refresh" if operation == "invoke" else "count"} + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +def test_req_qml017_ac02_absolute_sdk_spelling_still_rejects_global_source_class(tmp_path, operation): + """Absolute qualification bypasses locals, never a source declaration at ::.""" + _, accesses, links, _, _ = persisted(tmp_path, operation, "global_source_class", absolute=True) + assert not links and all(qt_metadata(node)["status"] != "resolved" for node in accesses) + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +def test_req_qml017_ac04_sdk_reflection_query_and_affected_use_exact_persisted_site(tmp_path, monkeypatch, capsys, operation): + """Consumers expose the same source-owned endpoint as the serialized graph.""" + from graphify.affected import affected_nodes, load_graph + from tests.qt_consumer_helpers import cli + _, accesses, links, _, output = persisted(tmp_path, operation, "sdk_control", directed=True) + site = accesses[0]["id"] + target = links[0]["target"] + assert "access.cpp" in cli(monkeypatch, capsys, output, "explain", site) + assert "Main.qml" in cli(monkeypatch, capsys, output, "query", site) + relation = "calls" if operation == "invoke" else "uses" + assert site in {hit.node_id for hit in affected_nodes(load_graph(output), target, relations=[relation], depth=1)} + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +def test_req_qml017_ac02_direct_collector_carries_type_rejection_and_original_span(tmp_path, operation): + """The child collector owns authority before resolver/facade annotation.""" + import copy + from graphify.extractors.qt_cpp_access import collect_qt_cpp_access + original = source(tmp_path, operation, "local_alias") + result = analysis(tmp_path, {"access.cpp": original, "Main.qml": QML}) + borrowed = copy.deepcopy(result) + fresh = {"nodes": [], "edges": []} + collect_qt_cpp_access([tmp_path / "access.cpp"], [fresh], root=tmp_path, + accepted_nodes=result["nodes"], accepted_edges=result["edges"]) + assert result == borrowed + rejected = [node for node in fresh["nodes"] if qt_metadata(node).get("reflection_supported") is False] + assert len(rejected) == 1 + md = qt_metadata(rejected[0]) + assert md["reflection_type"] == OPERATIONS[operation][2] + span = md["span"] + assert original.encode()[span["start_byte"]:span["end_byte"]].decode() in OPERATIONS[operation][1] + assert md["owner_id"] in {node["id"] for node in result["nodes"] if node.get("_callable")} + + +@pytest.mark.parametrize("tail", [ + 'QQmlProperty handle(root,"count"); QQmlProperty other(root,"count"); handle=other; handle.read();', + '{ QQmlProperty handle(root,"count"); handle.read(); } handle.read();', + 'QQmlProperty handle(root,"count"); handle=makeProperty(); handle.write(4);', +]) +def test_req_qml017_ac02_property_handle_reassignment_and_lifetime_cannot_borrow_old_target(tmp_path, tail): + """A previously established property wrapper cannot authorize another value/scope.""" + original = source(tmp_path, "invoke", "sdk_control").replace(OPERATIONS["invoke"][1], tail) + result = analysis(tmp_path, {"access.cpp": original, "Main.qml": QML}) + site = sites(result, "qml_access")[-1] + metadata = qt_metadata(site) + assert metadata["status"] != "resolved" and not metadata.get("target_id") + graph = build_from_json(result, root=tmp_path) + assert not any(data.get("_src", start) == site["id"] and data.get("context") in TARGET_CONTEXTS + for start, _, data in graph.edges(data=True)) + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +def test_req_qml017_ac02_sdk_identity_does_not_override_receiver_member_scope(tmp_path, operation): + """A genuine SDK API on a child still cannot access the root's members.""" + original = source(tmp_path, operation, "sdk_control").replace(OPERATIONS[operation][1], + 'auto child = root->findChild("details"); ' + OPERATIONS[operation][1].replace("root,", "child,")) + qml = 'import QtQml\nQtObject { property int count:1; function refresh(){} ' + qml += 'property QtObject child: QtObject { objectName:"details"; property int childOnly:2 } }' + result = analysis(tmp_path, {"access.cpp": original, "Main.qml": qml}) + accesses = [node for node in sites(result, "qml_access") if qt_metadata(node).get("operation") != "findChild"] + assert accesses and all(qt_metadata(node)["status"] != "resolved" for node in accesses) + graph = build_from_json(result, root=tmp_path, directed=True) + ids = {node["id"] for node in accesses} + assert not any(start in ids and data.get("context") in TARGET_CONTEXTS for start, _, data in graph.edges(data=True)) diff --git a/tests/test_qt_reflection_updates.py b/tests/test_qt_reflection_updates.py new file mode 100644 index 0000000000..bd1ea9bce3 --- /dev/null +++ b/tests/test_qt_reflection_updates.py @@ -0,0 +1,162 @@ +"""REQ-QML-017-AC04: SDK reflection refresh, rejection and durable recovery.""" +from __future__ import annotations + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated +from tests.test_qt_loader_updates import products +from tests.test_qt_reflection_type_identity import OPERATIONS, QML, TARGET_CONTEXTS, source + + +def cpp_source(root, operation, shadow="sdk_control"): + """Use a public accepted resource alias so metadata-only changes matter.""" + return source(root, operation, shadow).replace((root / "Main.qml").as_uri(), "qrc:/ui/Main.qml").encode() + + +def fixture(root, operation): + for name, content in { + "access.cpp": cpp_source(root, operation), "Main.qml": QML.encode(), + "CMakeLists.txt": b"qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES access.cpp)\n", + "ui.qrc": b'Main.qml', + "keep.py": b"def helper(): return 7\n\ndef retained(): return helper()\n", + }.items(): + (root / name).write_bytes(content) + return root / "access.cpp" + + +def reflection(graph): + """Only the reflection access sites own these endpoints, never loader edges.""" + sites = {identity: qt_metadata(data) for identity, data in graph.nodes(data=True) + if qt_metadata(data).get("kind") == "qml_access"} + edges = [(data.get("_src", a), data.get("_tgt", b), data) + for a, b, data in graph.edges(data=True) + if data.get("context") in TARGET_CONTEXTS and data.get("_src", a) in sites] + return sites, edges + + +def accepted(graph, operation): + sites, edges = reflection(graph) + assert len(sites) == len(edges) == (2 if operation == "property_handle" else 1) + assert all(md["status"] == "resolved" for md in sites.values()) + assert all(target == sites[identity]["target_id"] and graph.nodes[target]["source_file"] == "Main.qml" + for identity, target, _ in edges) + return sites, edges + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +@pytest.mark.parametrize("mode", ["manual", "watch"]) +def test_req_qml017_ac04_reflection_source_qml_metadata_edits_remove_stale_targets(tmp_path, monkeypatch, operation, mode): + """Cold/warm/full/update retain every public fact while authority changes.""" + cpp = fixture(tmp_path, operation) + monkeypatch.chdir(tmp_path) + cold = clean(tmp_path, tmp_path / ".cold") + assert normalized(cold) == normalized(clean(tmp_path, tmp_path / ".cold")) + initial = run(tmp_path, monkeypatch, mode) + assert normalized(initial) == normalized(cold) + original_sites, _ = accepted(initial, operation) + untouched, valid_cpp = unrelated(initial), cpp.read_bytes() + + cpp.write_bytes(cpp_source(tmp_path, operation, "local_alias")) + rejected = run(tmp_path, monkeypatch, mode, [cpp]) + assert not reflection(rejected)[1] + assert all(md["status"] != "resolved" and not md.get("target_id") for md in reflection(rejected)[0].values()) + assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".shadow-cold")) + cpp.write_bytes(valid_cpp) + assert normalized(run(tmp_path, monkeypatch, mode, [cpp])) == normalized(initial) + + qml = tmp_path / "Main.qml" + valid_qml = qml.read_bytes() + qml.write_bytes(valid_qml.replace(b"count:", b"renamed:").replace(b"function refresh()", b"function revised()")) + qml_changed = run(tmp_path, monkeypatch, mode, [qml]) + assert set(reflection(qml_changed)[0]) == set(original_sites) and cpp.read_bytes() == valid_cpp + assert not reflection(qml_changed)[1] + assert normalized(qml_changed) == normalized(clean(tmp_path, tmp_path / ".qml-cold")) + qml.write_bytes(valid_qml) + accepted(run(tmp_path, monkeypatch, mode, [qml]), operation) + + resource = tmp_path / "ui.qrc" + valid_resource = resource.read_bytes() + resource.write_bytes(valid_resource.replace(b'alias="Main.qml"', b'alias="Other.qml"')) + metadata_changed = run(tmp_path, monkeypatch, mode, [resource]) + assert set(reflection(metadata_changed)[0]) == set(original_sites) + assert not reflection(metadata_changed)[1] and cpp.read_bytes() == valid_cpp + assert normalized(metadata_changed) == normalized(clean(tmp_path, tmp_path / ".metadata-cold")) + resource.write_bytes(valid_resource) + accepted(run(tmp_path, monkeypatch, mode, [resource]), operation) + + cpp.write_bytes(valid_cpp.replace(OPERATIONS[operation][1].encode(), b"")) + removed = run(tmp_path, monkeypatch, mode, [cpp]) + assert reflection(removed) == ({}, []) + assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cold")) + assert unrelated(removed) == untouched + durable = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(removed) + assert products(tmp_path) == durable + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +@pytest.mark.parametrize("mode", ["manual", "force", "watch"]) +def test_req_qml017_ac04_reflection_parse_failure_retains_four_products_and_recovers(tmp_path, monkeypatch, operation, mode): + """A real malformed reflected source fails before accepted publication.""" + cpp = fixture(tmp_path, operation) + monkeypatch.chdir(tmp_path) + normal = "manual" if mode == "force" else mode + initial = run(tmp_path, monkeypatch, normal) + accepted(initial, operation) + valid, durable = cpp.read_bytes(), products(tmp_path) + cpp.write_bytes(valid + b"\r\nvoid broken({") + if mode == "watch": + from graphify.watch import _rebuild_code + assert not _rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True) + else: + with pytest.raises(SystemExit) as failure: + if mode == "force": + from tests.test_qt_cpp_upgrade_invalidation import cli + cli(tmp_path, monkeypatch, "update", force=True) + else: + run(tmp_path, monkeypatch, normal, [cpp]) + assert failure.value.code == 1 + assert products(tmp_path) == durable + cpp.write_bytes(valid) + recovered = run(tmp_path, monkeypatch, normal, [cpp]) + assert normalized(recovered) == normalized(initial) + repeated = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, normal, [])) == normalized(recovered) + assert products(tmp_path) == repeated + + +@pytest.mark.parametrize("operation", list(OPERATIONS)) +@pytest.mark.parametrize("mode", ["manual", "watch"]) +def test_req_qml017_ac04_reflection_write_failure_retains_four_products_and_retries(tmp_path, monkeypatch, operation, mode): + """Inject only the external replacement boundary after real analysis work.""" + import graphify.watch as watch + cpp = fixture(tmp_path, operation) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, mode) + accepted(initial, operation) + durable = products(tmp_path) + cpp.write_bytes(cpp_source(tmp_path, operation, "local_alias")) + real_replace = watch.os_replace_with_fallback + + def fail_replace(start, destination): + if str(destination).endswith("graph.json"): + raise PermissionError("injected reflection replacement failure") + return real_replace(start, destination) + + with monkeypatch.context() as failure: + failure.setattr(watch, "os_replace_with_fallback", fail_replace) + if mode == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, failure, mode, [cpp]) + assert rejected.value.code == 1 + else: + assert not watch._rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True) + assert products(tmp_path) == durable + recovered = run(tmp_path, monkeypatch, mode, [cpp]) + assert not reflection(recovered)[1] + assert normalized(recovered) != normalized(initial) + assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".recovered-cold")) + repeated = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(recovered) + assert products(tmp_path) == repeated diff --git a/tests/test_qt_resource_resolution.py b/tests/test_qt_resource_resolution.py new file mode 100644 index 0000000000..ecfe72a7d6 --- /dev/null +++ b/tests/test_qt_resource_resolution.py @@ -0,0 +1,189 @@ +"""QML-009-AC03 and QML-017: resource mapping, URL bases and accepted corpus.""" +import copy +import json +from pathlib import Path + +import pytest + +from graphify.extractors.qml import extract_qml +from graphify.extractors.qml_cmake import extract_cmake, parse_cmake +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qml_qmake import extract_qmake, parse_qmake +from graphify.extractors.qml_resources import extract_qrc, parse_qrc +from graphify.qml_resolution_types import fact_node +from graphify.qt_project_index import QtProjectIndex +from graphify.security import sanitize_metadata + + +def corpus(tmp_path, *, duplicate=False, locale=False): + source = tmp_path / "Main.qml" + source.write_bytes(b'Item { property int status: 1 }') + qml = extract_qml(source, root=tmp_path) + text = 'Main.qml' + if duplicate: + text += 'Main.qml' + text += '' + if locale: + text += 'Main.qml' + text += '' + qrc = parse_qrc(text) + assert not qrc.get("error") + return qml, qrc + + +@pytest.mark.parametrize("bom", [b"", b"\xef\xbb\xbf"]) +@pytest.mark.parametrize("newline", [b"\n", b"\r\n"]) +def test_qrc_original_byte_spans_utf8_aliases_and_roundtrip(tmp_path, bom, newline): + raw = bom + newline.join([b'', + ''.encode(), b'', + 'Main.qml'.encode(), b'']) + newline + path = tmp_path / "ui.qrc" + path.write_bytes(raw) + result = extract_qrc(path, root=tmp_path) + assert not result.get("error") + alias = next(n for n in result["nodes"] if qml_metadata(n)["kind"] == "resource_alias") + span = qml_metadata(alias)["span"] + assert raw[span["start_byte"]:span["end_byte"]] == 'Main.qml'.encode() + assert qml_metadata(alias)["logical_url"] == "qrc:/ui/Écran.qml" + assert qml_metadata(alias)["target_path"] == "Main.qml" + for node in result["nodes"]: + span = qml_metadata(node)["span"] + for end in ("start", "end"): + prefix = raw[:span[f"{end}_byte"]] + assert span[f"{end}_row"] == prefix.count(b"\n") + assert span[f"{end}_column"] == len(prefix) - prefix.rfind(b"\n") - 1 + + +def test_qrc_urls_resolve_only_accepted_component_and_explicit_runtime_base(tmp_path): + qml, qrc = corpus(tmp_path) + nodes, edges = qml["nodes"] + qrc["nodes"], qml["edges"] + qrc["edges"] + before = copy.deepcopy((nodes, edges)) + index = QtProjectIndex(nodes, edges, root=tmp_path) + component = next(n for n in qml["nodes"] if qml_metadata(n)["kind"] == "component") + for url in ("qrc:/ui/Écran.qml", ":/ui/Écran.qml", "qrc:/ui/%C3%89cran.qml"): + assert index.resolve_url(url).target_id == component["id"] + assert index.resolve_url("Écran.qml", "qrc:/ui/Other.qml").target_id == component["id"] + assert index.resolve_url("Main.qml").reason == "runtime_url_base_unavailable" + assert index.resolve_url((tmp_path / "Main.qml").as_uri()).target_id == component["id"] + assert index.resolve_url("Main.qml", (tmp_path / "Other.qml").as_uri()).target_id == component["id"] + for base in ("qrc://other/ui/Other.qml", "qrc:/ui/Other.qml?variant=1", + "qrc:/ui/Other.qml#fragment", "qrc:/ui/../Other.qml"): + assert index.resolve_url("Écran.qml", base).target_id is None + assert (nodes, edges) == before + sanitized = json.loads(json.dumps({"nodes": nodes, "edges": edges})) + for item in sanitized["nodes"] + sanitized["edges"]: + item["metadata"] = sanitize_metadata(item["metadata"]) + reload = QtProjectIndex(sanitized["nodes"], sanitized["edges"], root=tmp_path) + assert reload.resolve_url("qrc:/ui/Écran.qml") == index.resolve_url("qrc:/ui/Écran.qml") + + +@pytest.mark.parametrize("duplicate,locale,status,reason", [ + (True, False, "ambiguous", "duplicate_resource_alias"), + (False, True, "unsupported", "resource_locale_dependent"), +]) +def test_qrc_duplicate_and_locale_variants_never_guess(tmp_path, duplicate, locale, status, reason): + qml, qrc = corpus(tmp_path, duplicate=duplicate, locale=locale) + result = QtProjectIndex(qml["nodes"] + qrc["nodes"], [], root=tmp_path).resolve_url("qrc:/ui/Écran.qml") + assert (result.status, result.reason, result.target_id) == (status, reason, None) + + +@pytest.mark.parametrize("source,code", [ + (']>&x;', "QML_QRC_ENTITY"), + (']>', "QML_QRC_ENTITY"), + ('../../host.qml', "QML_QRC_PATH"), + ('Main.qml', "QML_QRC_PATH"), + ('Main.qml', "QML_QRC_PATH"), + ('C:/host.qml', "QML_QRC_PATH"), + ('', "QML_QRC_SYNTAX"), +]) +def test_qrc_host_reads_entities_traversal_and_malformed_xml_rejected(source, code): + result = parse_qrc(source) + assert result["qml_failures"] + assert result["diagnostics"][0]["code"] == code + assert not any(qml_metadata(n).get("kind") == "resource_alias" for n in result["nodes"]) + + +def test_qrc_missing_target_and_ignored_file_never_expand_corpus(tmp_path): + (tmp_path / "Main.qml").write_bytes(b"Item {}") + qrc = parse_qrc('Main.qml') + index = QtProjectIndex(qrc["nodes"], [], root=tmp_path) + assert index.resolve_url("qrc:/Main.qml").reason == "resource_target_outside_corpus" + for url in ("qrc:/ui/%2e%2e/Main.qml", "qrc://host/Main.qml", "https://host/Main.qml", + "qrc:/Main.qml?variant=1", "qrc:/Main.qml#fragment", "file:///host/Main.qml"): + assert index.resolve_url(url).target_id is None + + +def test_literal_build_context_and_module_resource_component_mapping(tmp_path): + qml, _ = corpus(tmp_path) + cpp = fact_node("service.h", "file", "cpp", "service.h", 1) + cmake = parse_cmake("qt_add_qml_module(app URI Public.Tools VERSION 1.2 " + "RESOURCE_PREFIX /ui QML_FILES Main.qml SOURCES service.h)") + nodes = qml["nodes"] + cmake["nodes"] + [cpp] + index = QtProjectIndex(nodes, [], root=tmp_path) + context = index.module_context("service.h") + assert context.status == "resolved" + assert context.target_id is not None + assert index.module_metadata(context.target_id)["uri"] == "Public.Tools" + component = index.resolve_component("Public.Tools", "Main") + assert component.status == "resolved" + assert index.resolve_url("qrc:/ui/Public/Tools/Main.qml").target_id == component.target_id + assert index.resolve_url("Main.qml", "qrc:/ui/Public/Tools/Other.qml").target_id == component.target_id + assert index.resolve_url("qrc:/ui/Public/Tools/%4dain.qml").target_id == component.target_id + assert index.resolve_component("Public.Tools", "Main", 1, 1).target_id == component.target_id + assert index.resolve_component("Public.Tools", "Main", 2, 0).target_id is None + assert index.resolve_component("Other.Tools", "Main").target_id is None + assert index.module_import("Public.Tools", 1, 2).status == "resolved" + assert index.module_import("Public.Tools", 2, 0).target_id is None + assert index.module_context("other/service.h").target_id is None + other = parse_cmake("qt_add_qml_module(other URI Other.Tools VERSION 1.0 SOURCES service.h)", + relative_file="other.cmake") + conflict = QtProjectIndex(nodes + other["nodes"], [], root=tmp_path).module_context("service.h") + assert conflict.status == "ambiguous" + assert conflict.evidence + duplicate = parse_cmake("qt_add_qml_module(other URI Public.Tools VERSION 1.2 QML_FILES Main.qml)", + relative_file="other.cmake") + assert QtProjectIndex(nodes + duplicate["nodes"], [], root=tmp_path).module_import("Public.Tools").status == "ambiguous" + + +def test_qmake_qrc_membership_resolves_the_same_component(tmp_path): + qml, qrc = corpus(tmp_path) + cpp = fact_node("service.h", "file", "cpp", "service.h", 1) + qmake = parse_qmake("QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.2\n" + "HEADERS += service.h\nRESOURCES += resources.qrc") + index = QtProjectIndex(qml["nodes"] + qrc["nodes"] + qmake["nodes"] + [cpp], [], root=tmp_path) + assert index.module_context("service.h").status == "resolved" + assert index.resolve_component("Public.Tools", "Main").target_id == index.resolve_url("qrc:/ui/Écran.qml").target_id + + +def test_public_qt6_cmake_and_qmake_fixtures_describe_identical_membership(): + root = Path(__file__).parent / "fixtures/qml/project_metadata" + qml = extract_qml(root / "Main.qml", root=root) + cpp = fact_node("backend.h", "file", "cpp", "backend.h", 1) + qrc = extract_qrc(root / "resources.qrc", root=root) + for metadata in (extract_cmake(root / "CMakeLists.txt", root=root), + extract_qmake(root / "tools.pro", root=root)): + assert not metadata.get("error") + index = QtProjectIndex(qml["nodes"] + qrc["nodes"] + metadata["nodes"] + [cpp], [], root=root) + context = index.module_context("backend.h") + assert context.target_id is not None + md = index.module_metadata(context.target_id) + assert (md["uri"], md["major"], md["minor"]) == ("Public.Tools", 1, 0) + assert index.resolve_component("Public.Tools", "Main").target_id == index.resolve_url("qrc:/ui/Public/Tools/Main.qml").target_id + + +@pytest.mark.parametrize("same_file", [False, True]) +def test_same_target_name_does_not_coalesce_distinct_module_providers(tmp_path, same_file): + declaration = "qt_add_qml_module(app URI Public.Tools VERSION 1.0)\n" + if same_file: + results = [parse_cmake(declaration * 2)] + else: + results = [parse_cmake(declaration, relative_file=file) for file in + ("first/CMakeLists.txt", "second/CMakeLists.txt")] + nodes = [node for result in results for node in result["nodes"]] + providers = [node["id"] for node in nodes if qml_metadata(node).get("kind") == "qt_module"] + assert len(set(providers)) == 2 + index = QtProjectIndex(nodes, [], root=tmp_path) + resolution = index.module_import("Public.Tools", 1, 0) + assert resolution.status == "ambiguous" and resolution.target_id is None + assert set(resolution.evidence) == set(resolution.candidates) == set(providers) + assert index.resolve_component("Public.Tools", "Main", 1, 0).status == "ambiguous" diff --git a/tests/test_qt_semantic_correction_updates.py b/tests/test_qt_semantic_correction_updates.py new file mode 100644 index 0000000000..6493c7e1ec --- /dev/null +++ b/tests/test_qt_semantic_correction_updates.py @@ -0,0 +1,157 @@ +"""Real CLI/watch lifecycle for receiver, engine, alias and loader corrections.""" +from __future__ import annotations + +import pytest + +import graphify.extract as extraction +import graphify.qt_incremental as policy +from graphify.affected import affected_nodes, load_graph, resolve_seed +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated + + +def fixture(root): + """A public resource fixture keeps native handles fixed while parenting changes.""" + sources = { + "Main.qml": 'import QtQuick\nItem { property int value: 9; ' + 'Item { objectName: "left"; property int value: 1; ' + 'Item { objectName: "deep"; property int value: 2 } } }', + "access.cpp": 'void use() { QQuickView view; view.setSource(QUrl("qrc:/ui/Main.qml")); ' + 'auto root = view.rootObject(); auto left = root->findChild("left"); ' + 'left->property("value"); auto deep = left->findChild("deep"); }', + "ui.qrc": 'Main.qml', + "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n', + } + for name, content in sources.items(): + (root / name).write_text(content, encoding="utf-8", newline="") + return root / "Main.qml" + + +def access(graph): + return {identity: qt_metadata(node) for identity, node in graph.nodes(data=True) + if qt_metadata(node).get("kind") == "qml_access"} + + +def products(root): + output = root / "graphify-out" + return {name: (output / name).read_bytes() for name in + ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")} + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml017_ac04_receiver_tree_and_member_updates_remove_stale_edges(tmp_path, monkeypatch, operation): + """Lost child members cannot borrow the root; moved children cannot stay descendants.""" + source = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + original_cpp = (tmp_path / "access.cpp").read_bytes() + initial = run(tmp_path, monkeypatch, operation) + before = access(initial) + assert len(before) == 3 and all(md["status"] == "resolved" for md in before.values()) + assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold")) + assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold")) + consumer = load_graph(tmp_path / "graphify-out/graph.json") + for identity, md in before.items(): + assert resolve_seed(initial, identity, tmp_path) == identity + assert identity in {hit.node_id for hit in affected_nodes(consumer, md["target_id"], relations=["uses"], depth=1)} + source.write_text('import QtQuick\nItem { property int value: 9; ' + 'Item { objectName: "left" } ' + 'Item { objectName: "deep"; property int value: 2 } }', encoding="utf-8") + edited = run(tmp_path, monkeypatch, operation, [source]) + after = access(edited) + assert set(after) == set(before) + lost = [identity for identity, md in after.items() if md["status"] != "resolved"] + assert len(lost) == 2 + for identity in lost: + assert not after[identity].get("target_id") + assert after[identity]["span"] == before[identity]["span"] + assert not any(data.get("_src", start) == identity and data.get("relation") == "uses" + for start, _, data in edited.edges(data=True)) + assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited")) + assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited")) + assert unrelated(initial) == unrelated(edited) + assert (tmp_path / "access.cpp").read_bytes() == original_cpp + assert normalized(run(tmp_path, monkeypatch, operation, [source])) == normalized(edited) + unchanged = products(tmp_path) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(edited) + assert products(tmp_path) == unchanged + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml017_ac04_policy_refresh_failure_retains_products_and_retry(tmp_path, monkeypatch, operation): + """A same-package policy upgrade and real parse failure retain every accepted product.""" + source = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + with monkeypatch.context() as previous: + previous.setattr(policy, "QT_POLICY_VERSION", 6) + initial = run(tmp_path, previous, operation) + before, observed, real = products(tmp_path), [], extraction.extract + + def observe(paths, **kwargs): + observed.extend(paths) + return real(paths, **kwargs) + + monkeypatch.setattr(extraction, "extract", observe) + refreshed = run(tmp_path, monkeypatch, operation, []) + assert tmp_path / "access.cpp" in observed and source in observed + assert normalized(refreshed) == normalized(initial) + assert products(tmp_path)[".qt_analysis.json"] != before[".qt_analysis.json"] + valid, accepted = source.read_bytes(), products(tmp_path) + source.write_text("Item { property int value: }", encoding="utf-8") + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, monkeypatch, operation, [source]) + assert rejected.value.code == 1 + else: + from graphify.watch import _rebuild_code + assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + assert products(tmp_path) == accepted + source.write_bytes(valid) + recovered = run(tmp_path, monkeypatch, operation, [source]) + assert normalized(recovered) == normalized(refreshed) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered) + + +@pytest.mark.parametrize("failure_stage", ["join", "replacement"]) +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml017_ac04_failed_completion_keeps_prior_graph_and_retries(tmp_path, monkeypatch, operation, failure_stage): + """Real resolution/write work must complete before accepted graph/state replacement.""" + import graphify.watch as watch + from graphify.qt_qml_access_index import QtQmlAccessIndex + from graphify.watch import _rebuild_code + + source = fixture(tmp_path) + monkeypatch.chdir(tmp_path) + initial = run(tmp_path, monkeypatch, operation) + accepted = products(tmp_path) + # The declared type is observable in source facts; numeric initializer + # evaluation is outside this analyzer and would not force a graph write. + source.write_bytes(source.read_bytes().replace(b"property int value: 1", b"property real value: 1")) + real_member, real_replace = QtQmlAccessIndex.member, watch.os_replace_with_fallback + + def failed_member(*args, **kwargs): + assert real_member(*args, **kwargs).target_id + raise RuntimeError("injected resolution completion failure") + + def failed_replace(start, destination): + if str(destination).endswith("graph.json"): + raise PermissionError("injected graph replacement failure") + return real_replace(start, destination) + + with monkeypatch.context() as failed: + if failure_stage == "join": + failed.setattr(QtQmlAccessIndex, "member", failed_member) + else: + failed.setattr(watch, "os_replace_with_fallback", failed_replace) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, failed, operation, [source]) + assert rejected.value.code == 1 + else: + assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True) + assert products(tmp_path) == accepted + recovered = run(tmp_path, monkeypatch, operation, [source]) + assert normalized(recovered) != normalized(initial) + assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".repaired")) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered) diff --git a/tests/test_qt_short_leaf_aliases.py b/tests/test_qt_short_leaf_aliases.py new file mode 100644 index 0000000000..054ff0e56f --- /dev/null +++ b/tests/test_qt_short_leaf_aliases.py @@ -0,0 +1,151 @@ +"""REQ-QML-020: Windows native file spellings preserve accepted source identities.""" +from __future__ import annotations + +import ctypes +import json +import os +from pathlib import Path + +import pytest + +import graphify.extract as extraction +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.paths import load_node_link_graph +from tests.test_qt_final_incremental_parity import normalized, project +from tests.test_source_alias_provenance import directory_alias + + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="Actual Windows native short-file spelling") +HEADER = "LongCanonicalBackendDeclaration.h" +NAMES = (HEADER, "Main.qml", "loader.cpp", "CMakeLists.txt", "resources.qrc", "keep.py") +PYTHON = b"def helper():\n return 7\n\ndef caller():\n return helper()\n" + + +def short_leaf(path): + """Shorten the real leaf alone, retaining its walked parent-directory owner.""" + api = ctypes.windll.kernel32.GetShortPathNameW + api.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32) + api.restype = ctypes.c_uint32 + buffer = ctypes.create_unicode_buffer(32768) + length = api(str(path), buffer, len(buffer)) + if not length or length >= len(buffer): + pytest.skip("Filesystem does not expose a native short-file spelling") + alias = path.parent / Path(buffer.value).name + if alias == path: + pytest.skip("Filesystem has no distinct short spelling for this leaf") + assert alias.resolve() == path.resolve() + return alias + + +def fixture(root): + """Retain public Qt membership declarations and original BOM/CRLF source bytes.""" + project(root) + old, header = root / "backend.h", root / HEADER + header.write_bytes(b"\xef\xbb\xbf// Unicode \xcf\x80\r\n" + old.read_bytes().replace(b"\n", b"\r\n")) + old.unlink() + cmake = root / "CMakeLists.txt" + cmake.write_bytes(cmake.read_bytes().replace(b"backend.h", HEADER.encode())) + return [root / name for name in NAMES] + + +def graph(paths, root, cache): + result = extraction.extract(paths, root=root, cache_root=cache, parallel=False) + assert not result["failed_sources"] and not result["qml_failures"] + return build_from_json(result, root=root, directed=True), result + + +@pytest.mark.parametrize("selection", ["header", "build_metadata", "both"]) +def test_req_qml020_ac01_short_native_leaf_preserves_membership_facts_and_reload( + tmp_path, selection, monkeypatch): + """One NTFS entry has identical IDs, spans, memberships and cold/warm products.""" + root = tmp_path.resolve() + paths = fixture(root) + spellings = {"header": {HEADER}, "build_metadata": {"CMakeLists.txt"}, + "both": {HEADER, "CMakeLists.txt"}}[selection] + aliased = [short_leaf(path) if path.name in spellings else path for path in paths] + before = {name: (root / name).read_bytes() for name in NAMES} + expected, expected_result = graph(paths, root, root / "canonical-cache") + actual, result = graph(aliased, root, root / "alias-cache") + memberships = [qml_metadata(data) for _, data in actual.nodes(data=True) + if qml_metadata(data).get("kind") == "membership_resolution"] + assert memberships and all(item["status"] == "resolved" for item in memberships) + assert normalized(actual) == normalized(expected) + assert result["nodes"] == expected_result["nodes"] and result["edges"] == expected_result["edges"] + observed, real = [], extraction._safe_extract_with_xaml_root + + def record(extractor, path, anchor): + observed.append(path.name) + return real(extractor, path, anchor) + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", record) + warm, _ = graph(aliased, root, root / "alias-cache") + assert "keep.py" not in observed and normalized(warm) == normalized(expected) + output = root / "reloaded.json" + assert to_json(warm, {}, str(output), force=True) + reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert normalized(reloaded) == normalized(expected) + assert before == {name: (root / name).read_bytes() for name in NAMES} + + +def test_req_qml020_ac02_short_spelling_preserves_two_discovered_junction_owners( + tmp_path, directory_alias): + """Native spelling expansion preserves separate source owners and actual calls.""" + root = tmp_path.resolve() + paths = fixture(root) + real = root / "CanonicalRealDirectory" + real.mkdir() + source = real / "LongCanonicalFunctionSource.py" + source.write_bytes(PYTHON) + link = directory_alias(root / "DiscoveredJunctionDirectory", real) + walked = [source, link / source.name] + expected, _ = graph([*paths, *walked], root, root / "canonical-cache") + actual, result = graph([*paths, *(short_leaf(path) for path in walked)], root, root / "alias-cache") + identities = { + path.relative_to(root).as_posix(): {node["id"] for node in result["nodes"] + if node.get("source_file") == path.relative_to(root).as_posix()} + for path in walked + } + first, second = identities.values() + assert len(first) == len(second) == 3 and first.isdisjoint(second) + for source_file, owned in identities.items(): + calls = [edge for edge in result["edges"] if edge.get("source_file") == source_file + and edge.get("context") == "call"] + assert calls and all(edge["source"] in owned and edge["target"] in owned for edge in calls) + assert normalized(actual) == normalized(expected) + warm, _ = graph([*paths, *(short_leaf(path) for path in walked)], root, root / "alias-cache") + assert normalized(warm) == normalized(expected) + assert source.read_bytes() == PYTHON + + +def test_req_qml020_ac02_short_foreign_native_leaf_keeps_existing_root_rejection( + tmp_path, directory_alias): + """A local spelling of a foreign QObject still gains no Qt endpoint authority.""" + root, foreign = tmp_path / "repo", tmp_path / "foreign" + root.mkdir() + foreign.mkdir() + paths = fixture(root) + target = foreign / "LongForeignBackendDeclaration.h" + target.write_bytes(b"class Foreign : public QObject { Q_OBJECT public: Q_INVOKABLE void run(); };\n") + link = directory_alias(root / "ForeignJunctionDirectory", foreign) + alias = short_leaf(link / target.name) + before = target.read_bytes() + assert extraction.collect_files(link, follow_symlinks=True, root=root) == [] + result = extraction.extract([*paths, alias], root=root, cache_root=root / "cache", parallel=False) + assert result["failed_sources"] and {item["code"] for item in result["qml_failures"]} == {"QT_CPP_ROOT"} + assert not [node for node in result["nodes"] if node.get("label") == "Qt class: Foreign"] + assert target.read_bytes() == before + + +def test_req_qml020_ac01_native_utf16_lengths_preserve_astral_source_names(tmp_path): + """Actual Win32 WCHAR lengths accept supplementary characters in owners and leaves.""" + root = tmp_path.resolve() + parent = root / "PublicAstralDirectory\U0001f680" + parent.mkdir() + source = parent / "LongAstralFunctionSource\U0001f680.py" + source.write_bytes(PYTHON) + canonical, _ = graph([source], root, root / "canonical-cache") + actual, _ = graph([short_leaf(source)], root, root / "alias-cache") + assert normalized(actual) == normalized(canonical) + assert source.read_bytes() == PYTHON diff --git a/tests/test_qt_signals_slots.py b/tests/test_qt_signals_slots.py new file mode 100644 index 0000000000..0d76a045b1 --- /dev/null +++ b/tests/test_qt_signals_slots.py @@ -0,0 +1,72 @@ +"""QML-016 native event success, rejection and source-provenance acceptance.""" +import json + +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_analysis_helpers import analysis, sites + +SOURCE = '''class Sender : public QObject { + Q_OBJECT +signals: + void changed(int value); +public: + void run() { emit changed(1); Q_EMIT changed(2); changed(3); } +}; +class Receiver : public QObject { + Q_OBJECT +public: + void accept(int value) {} +private slots: + void hidden(int value) {} +}; +void wire(Sender *sender, Receiver *receiver) { + QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept, Qt::QueuedConnection); + QObject::connect(sender, SIGNAL(changed(int)), receiver, SLOT(hidden(int))); + QObject::connect(sender, &Sender::changed, receiver, [](int value) {}, Qt::UniqueConnection); + QObject::disconnect(sender, &Sender::changed, receiver, &Receiver::accept); +} +''' + + +def test_native_events_have_distinct_sites_and_no_delivery_calls(tmp_path): + result = analysis(tmp_path, {"events.cpp": SOURCE}) + emissions = sites(result, "emission") + assert len(emissions) == 3 + assert all(qt_metadata(node)["status"] == "resolved" for node in emissions) + connections = sites(result, "connect") + assert len(connections) == 3 + assert all(qt_metadata(node)["status"] == "resolved" for node in connections) + assert len(sites(result, "disconnect")) == 1 + assert qt_metadata(sites(result, "disconnect")[0])["status"] == "resolved" + assert qt_metadata(connections[0])["declared_type"] == "QueuedConnection" + assert qt_metadata(connections[-1])["unique_connection_effect"] == "not_guaranteed_for_functors" + event_ids = {node["id"] for node in [*emissions, *connections]} + assert not [edge for edge in result["edges"] if edge["source"] in event_ids and edge["relation"] == "calls"] + assert len({node["id"] for node in emissions}) == 3 + assert all(qt_metadata(node)["span"]["start_byte"] < qt_metadata(node)["span"]["end_byte"] for node in emissions) + + +def test_macro_sections_private_meta_slots_and_comments(tmp_path): + source = SOURCE.replace("signals:", "Q_SIGNALS:").replace("private slots:", "private Q_SLOTS:") + source += '\n// QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept);\nconst char *text = "emit changed(9)";' + result = analysis(tmp_path, {"events.cpp": source}) + assert len(sites(result, "connect")) == 3 + hidden = [node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "hidden"] + assert len(hidden) == 1 and qt_metadata(hidden[0])["access"] == "private" + assert qt_metadata(hidden[0])["roles"] == ["slot"] + + +def test_overloads_need_selector_and_dynamic_sender_stays_unresolved(tmp_path): + source = SOURCE.replace("void changed(int value);", "void changed(int value);\n void changed(double value);") + source = source.replace("&Sender::changed, receiver, &Receiver::accept, Qt::QueuedConnection", "qOverload(&Sender::changed), receiver, &Receiver::accept, Qt::QueuedConnection") + result = analysis(tmp_path, {"events.cpp": source}) + connections = sites(result, "connect") + assert qt_metadata(connections[0])["status"] == "resolved" + assert qt_metadata(connections[2])["signal_status"] == "ambiguous" + result2 = analysis(tmp_path, {"events.cpp": SOURCE.replace("QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept", "QObject::connect(factory(), &Sender::changed, receiver, &Receiver::accept")}) + assert qt_metadata(sites(result2, "connect")[0])["status"] != "resolved" + + +def test_nested_literal_metadata_round_trip_is_exact(tmp_path): + result = analysis(tmp_path, {"events.cpp": SOURCE.replace("int value", "QList value").replace("changed(int)", "changed(QList)").replace("hidden(int)", "hidden(QList)")}) + node = sites(result, "connect")[1] + assert qt_metadata(json.loads(json.dumps(node)))["signal"]["parameter_types"] == ["QList"] diff --git a/tests/test_qt_source_coverage_report.py b/tests/test_qt_source_coverage_report.py new file mode 100644 index 0000000000..16dab65f9b --- /dev/null +++ b/tests/test_qt_source_coverage_report.py @@ -0,0 +1,77 @@ +"""QML-010/013 reports distinguish source resolution coverage from Qt runtime proof.""" +import copy +import json + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qt_coverage import source_coverage +from graphify.report import generate +from tests.qt_analysis_helpers import analysis, sites + + +def report(graph): + return generate(graph, {0: list(graph)}, {0: 0.5}, {0: "Source facts"}, [], [], + {"total_files": 2, "total_words": 100, "warning": None}, {}, "public-project") + + +def corpus(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use(QUrl computed){{ QQmlApplicationEngine engine; + engine.load(QUrl("{url}")); engine.load(computed); + engine.load(QUrl("https://example.test/Main.qml")); engine.load(QUrl("qrc:/missing.qml")); + engine.load(QUrl("qrc:/duplicate.qml")); }}''' + qrc = 'Main.qmlMain.qml' + return analysis(tmp_path, {"Main.qml": 'import QtQml\nQtObject { property int value: unknown }', + "use.cpp": source, "resources.qrc": qrc}) + + +def test_report_actual_source_status_counts_survive_json_reload_and_do_not_mutate_graph(tmp_path): + """Real supported, computed, duplicate and unavailable loads stay distinct.""" + result = corpus(tmp_path) + graph = build_from_json(result, root=tmp_path) + states = {qt_metadata(node)["status"] for node in sites(result, "qml_load")} + assert states == {"resolved", "dynamic", "unsupported", "unavailable", "ambiguous"} + before = copy.deepcopy((dict(graph.nodes(data=True)), list(graph.edges(data=True)))) + output = tmp_path / "graph.json" + assert to_json(graph, {}, str(output), force=True) + loaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert source_coverage(loaded) == source_coverage(graph) + summary = report(loaded) + for state in ("Resolved", "Ambiguous", "Dynamic", "Unsupported", "Unavailable", "Error", "Other / no status"): + assert state in summary + row = source_coverage(graph)["qt"] + assert all(row[status] >= 1 for status in states) + assert "not a percentage of supported language syntax" in summary + assert "signal delivery, ordering, or thread safety" in summary + assert "absence from this graph is not proof" in summary + assert (dict(graph.nodes(data=True)), list(graph.edges(data=True))) == before + + +def test_report_invalid_source_transport_counts_as_error_without_exposing_payload(tmp_path): + """Corrupt accepted-source metadata remains an explicit bounded reporting gap.""" + result = corpus(tmp_path) + graph = build_from_json(result, root=tmp_path) + site = sites(result, "qml_load")[0] + raw = graph.nodes[site["id"]]["metadata"]["qt"]["raw_values"] + raw["raw_name"] = "" + count = source_coverage(graph)["qt"] + assert count["error"] == 1 and count["invalid_metadata"] == 1 + summary = report(graph) + assert "1 source fact(s) have invalid semantic metadata" in summary + assert "private-payload" not in summary + + +def test_report_future_or_unowned_metadata_cannot_claim_source_coverage(tmp_path): + """The new section stays absent for generic graphs and unsupported contracts.""" + result = analysis(tmp_path, {"plain.py": 'def ordinary():\n return 1\n'}) + graph = build_from_json(result, root=tmp_path) + assert "Qt/QML source coverage" not in report(graph) + node = next(iter(graph)) + graph.nodes[node]["metadata"] = {"qt": {"contract_version": 2, "kind": "connect", "status": "resolved", + "span": {"start_byte": 0, "end_byte": 1}}} + assert not source_coverage(graph) + graph.nodes[node]["metadata"]["qt"]["contract_version"] = 1 + graph.nodes[node]["metadata"]["qt"].pop("span") + assert not source_coverage(graph) and "Qt/QML source coverage" not in report(graph) diff --git a/tests/test_qt_source_file_containment.py b/tests/test_qt_source_file_containment.py new file mode 100644 index 0000000000..7867cf0465 --- /dev/null +++ b/tests/test_qt_source_file_containment.py @@ -0,0 +1,128 @@ +"""REQ-QML-008-AC02 retain source context without guessing native owners.""" +import copy +import json +from collections import Counter + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_source_containment import attach_qt_file_sites +from tests.qt_analysis_helpers import analysis, sites + + +HEADER = """class Backend : public QObject { + Q_OBJECT +public: + Backend(); + Backend(int count); +signals: + void ready(); +}; +""" +CPP = '#include "backend.hpp"\nBackend::Backend() : Backend(0) { emit ready(); }\n' + + +def corpus(root): + """Duplicate equivalent constructor bodies cannot prove one source callable owner.""" + cpp = CPP + CPP.split('\n', 1)[1] + result = analysis(root, {"backend.hpp": HEADER, "backend.cpp": cpp}) + unknown = [node for node in sites(result, "member") + sites(result, "emission") + if not qt_metadata(node)["owner_id"]] + assert unknown + return result, unknown + + +def test_req_qml008_ac02_unknown_native_owner_keeps_actual_file_context_after_reload(tmp_path): + """The production facade/build/writer retain truthful source context and unknown roles.""" + # Equivalent duplicate definitions remain ambiguous after exact overload + # admission; a source file link cannot turn them into accepted native owners. + cpp = CPP + CPP.split('\n', 1)[1] + result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": cpp}) + unknown = [node for node in sites(result, "member") + sites(result, "emission") + if not qt_metadata(node)["owner_id"]] + assert unknown + for site in unknown: + assert any(edge["target"] == site["id"] and edge["context"] == "qt_source_file" + and edge["confidence"] == "EXTRACTED" for edge in result["edges"]) + assert qt_metadata(site)["owner_id"] == "" + graph = build_from_json(result, root=tmp_path) + output = tmp_path / "graph.json" + assert to_json(graph, {}, str(output)) + reloaded = build_from_json(json.loads(output.read_text(encoding="utf-8")), root=tmp_path) + assert all(reloaded.degree(site["id"]) for site in unknown) + assert all(qt_metadata(reloaded.nodes[site["id"]])["owner_id"] == "" for site in unknown) + assert all(qt_metadata(site)["status"] != "resolved" for site in unknown + if qt_metadata(site)["kind"] == "emission") + + +@pytest.mark.parametrize("corruption", [None, "missing", "foreign", "outside_root", "duplicate", "callable", "class", "non_ast", "unmarked", "fresh_unmarked"]) +def test_req_qml008_ac02_file_context_requires_unique_actual_file_role(tmp_path, corruption): + """Borrowed file identities cannot be guessed from a label or fabricated path ID.""" + result, unknown = corpus(tmp_path) + nodes = copy.deepcopy(result["nodes"]) + file = next(node for node in nodes if node.get("label") == "backend.cpp") + if corruption == "missing": + nodes.remove(file) + elif corruption == "foreign": + file["source_file"] = "outside.cpp" + elif corruption == "outside_root": + file["source_file"] = "../backend.cpp" + elif corruption == "duplicate": + nodes.append({**file, "id": file["id"] + "_competing"}) + elif corruption == "callable": + file["_callable"] = True + elif corruption == "class": + file["_callable_class"] = True + elif corruption == "non_ast": + file["_origin"] = "llm" + elif corruption in {"unmarked", "fresh_unmarked"}: + file.pop("_origin", None) + before = copy.deepcopy(nodes) + fresh = {"nodes": copy.deepcopy(unknown), "edges": []} + metadata = copy.deepcopy(fresh["nodes"]) + fresh_ids = {file["id"]} if corruption == "fresh_unmarked" else () + added = attach_qt_file_sites({tmp_path / "backend.cpp": fresh}, nodes, [], root=tmp_path, + fresh_ast_ids=fresh_ids) + assert nodes == before and fresh["nodes"] == metadata + assert bool(added) == (corruption in {None, "fresh_unmarked"}) + assert not attach_qt_file_sites({tmp_path / "backend.cpp": fresh}, nodes, added, root=tmp_path) + for edge in added: + assert edge["source"] == file["id"] and edge["context"] == "qt_source_file" + assert edge["_src"] == edge["source"] and edge["_tgt"] == edge["target"] + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac04_unowned_file_sites_refresh_and_remove_stale_links(tmp_path, monkeypatch, operation): + """Actual full/warm/update paths preserve Qt file links and evict removed sites. + + Equivalent duplicate definitions keep visible source occurrences without a + guessed callable. Compare all nodes and every native edge; exact accepted + overload tests separately establish complete generic graph parity. + """ + from tests.test_qt_final_incremental_parity import clean, endpoints, normalized, run, semantic + + def native_snapshot(graph): + nodes, _ = normalized(graph) + native = Counter((*endpoints(graph, source, target, data), + json.dumps(semantic(data), sort_keys=True, ensure_ascii=False)) + for source, target, data in graph.edges(data=True) if qt_metadata(data)) + return nodes, native + + corpus(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + cold = clean(tmp_path, tmp_path / ".cold-cache") + assert native_snapshot(clean(tmp_path, tmp_path / ".cold-cache")) == native_snapshot(cold) + initial = run(tmp_path, monkeypatch, operation) + assert native_snapshot(initial) == native_snapshot(cold) + retained = {identity for identity, data in initial.nodes(data=True) + if qt_metadata(data).get("kind") == "emission" and not qt_metadata(data)["owner_id"]} + assert retained and all(initial.degree(identity) for identity in retained) + path = tmp_path / "backend.cpp" + path.write_bytes(path.read_bytes().replace(b"emit ready();", b"")) + changed = run(tmp_path, monkeypatch, operation, [path]) + assert not retained.intersection(changed) + assert native_snapshot(changed) == native_snapshot(clean(tmp_path, tmp_path / ".changed-cache")) + assert native_snapshot(run(tmp_path, monkeypatch, operation, [])) == native_snapshot(changed) diff --git a/tests/test_qt_source_identity_admission.py b/tests/test_qt_source_identity_admission.py new file mode 100644 index 0000000000..3c8c6d96ce --- /dev/null +++ b/tests/test_qt_source_identity_admission.py @@ -0,0 +1,76 @@ +"""INC-QML-50: physical admission precedes lexical source labels and diagnostics.""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +import graphify.source_identity as input_identity +from tests.test_qt_followed_metadata_alias import CASES, owned_facts, reader_for +from tests.test_source_alias_provenance import directory_alias # noqa: F401 + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX physical link/.. traversal semantics") +@pytest.mark.parametrize("inside", [False, True]) +@pytest.mark.parametrize("name,key,text", CASES, ids=[case[0] for case in CASES]) +def test_req_qml020_ac02_dotdot_input_keeps_actual_physical_target_and_containment( + tmp_path, directory_alias, monkeypatch, inside, name, key, text): + """Actual symlink/.. input cannot borrow a lexical trap file's source name or root admission.""" + root = tmp_path / "repo" + root.mkdir() + physical_parent = root / "real" if inside else tmp_path / "outside" + (physical_parent / "deep").mkdir(parents=True) + raw = b"\xef\xbb\xbf" + text.replace("\n", "\r\n").encode("utf-8") + (physical_parent / name).write_bytes(raw) + (root / name).write_bytes(b"\xfflexical-trap") + directory_alias(root / "link", physical_parent / "deep") + input_path = root / "link" / ".." / name + assert input_path.resolve() == physical_parent / name + opened, real_open = [], Path.open + + def record(path, *args, **kwargs): + opened.append(path) + return real_open(path, *args, **kwargs) + + monkeypatch.setattr(Path, "open", record) + result = reader_for(key)(input_path, root=root) + if inside: + owned_facts(result, f"real/{name}", raw) + assert opened and all(path.resolve() == physical_parent / name for path in opened) + else: + assert result.get("error") and not result["nodes"] and not result["edges"] + assert not opened + + +@pytest.mark.skipif(os.name != "nt", reason="Native Windows source spelling admission") +@pytest.mark.parametrize("name,key,text", [CASES[index] for index in (0, 1, 2, 4, 6, 7)], + ids=["qml", "qmldir", "cmake", "qmake", "qrc", "qmltypes"]) +def test_req_qml020_ac02_native_admission_failure_keeps_its_code_and_recovers( + tmp_path, monkeypatch, capsys, name, key, text): + """A failed real native API seam retains the existing code, omits backend bodies and repairs cleanly.""" + source = tmp_path / name + raw = text.encode("utf-8") + source.write_bytes(raw) + reader = reader_for(key) + accepted = reader(source, root=tmp_path) + assert accepted["nodes"] and not accepted.get("error") + observed = [] + private_body = "private-native-backend-body\nsecond-delimiter" + + def failed_native(path): + observed.append(path) + raise OSError(private_body) + + with monkeypatch.context() as failed: + failed.setattr(input_identity, "_native_long_name", failed_native) + result = reader(source, root=tmp_path) + assert observed and result.get("error") and not result["nodes"] and not result["edges"] + assert all(item["code"] == "SOURCE_INPUT_IDENTITY_FAILED" for item in result["diagnostics"]) + assert all(item["source_file"] == "" for item in result["diagnostics"]) + assert "QML_ROOT" not in repr(result) + assert all(fragment not in repr(result) for fragment in private_body.splitlines()) + output = capsys.readouterr() + assert all(fragment not in output.out + output.err for fragment in private_body.splitlines()) + assert reader(source, root=tmp_path) == accepted == reader(source, root=tmp_path) + assert source.read_bytes() == raw diff --git a/tests/test_qt_source_links_upgrade.py b/tests/test_qt_source_links_upgrade.py new file mode 100644 index 0000000000..e36647f6c5 --- /dev/null +++ b/tests/test_qt_source_links_upgrade.py @@ -0,0 +1,106 @@ +"""REQ-QML-011/012 refresh source containment and preserve durable products.""" +from __future__ import annotations + +import pytest + +import graphify.cache as cache +import graphify.extract as extraction +import graphify.qt_incremental as policy +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_analysis_state import QT_STATE_FILE, inspect_qt_analysis +from tests.test_qt_cpp_upgrade_invalidation import cli, unchanged_python +from tests.test_qt_final_incremental_parity import clean, normalized + + +SOURCES = { + "helper.hpp": "class Helper { public: void run(int count); };\n", + "helper.cpp": '#include "helper.hpp"\nvoid Helper::run(int count) { Q_UNUSED(count) }\n', + "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n", +} + + +def prior_products(root, monkeypatch, epochs=(3, 6)): + """Publish real products under prior epochs, without replacing CLI behavior. + + This proves upgrade/invalidation at the persisted boundary; separate source + regressions reproduce the actual old missing edges before correction. + """ + paths = [] + for name, content in SOURCES.items(): + path = root / name + path.write_text(content, encoding="utf-8", newline="") + paths.append(path) + with monkeypatch.context() as historical: + historical.setattr(policy, "QT_POLICY_VERSION", epochs[0]) + historical.setattr(cache, "_AST_CACHE_SCHEMA", epochs[1]) + graph = cli(root, historical, "extract") + assert not inspect_qt_analysis(root, root / "graphify-out", paths).changed + prior_cache = cache.cache_dir(root) + return paths, graph, prior_cache + + +@pytest.mark.parametrize("operation", ["extract", "update"]) +@pytest.mark.parametrize("epochs", [(3, 6), (4, 7)]) +def test_req_qml011_ac03_source_links_upgrade_reparses_unchanged_cpp(tmp_path, monkeypatch, operation, epochs): + """The same installed version cannot retain old orphan facts or old AST output.""" + monkeypatch.chdir(tmp_path) + paths, before, old_cache = prior_products(tmp_path, monkeypatch, epochs) + sources = {path: path.read_bytes() for path in paths} + assert inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed + observed, real = [], extraction._safe_extract_with_xaml_root + + def observe(extractor, path, root): + observed.append(path) + return real(extractor, path, root) + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe) + current = cli(tmp_path, monkeypatch, operation) + assert set(paths[:2]).issubset(observed) + if epochs[1] != cache._AST_CACHE_SCHEMA: + assert not old_cache.exists() + assert not inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed + members = [(identity, qt_metadata(data)) for identity, data in current.nodes(data=True) + if qt_metadata(data).get("kind") == "member"] + assert len(members) == 1 + identity, metadata = members[0] + assert not metadata["class_id"] + assert metadata["owner_id"] == metadata["generic_target_id"] and current.degree(identity) + assert unchanged_python(before) == unchanged_python(current) + assert sources == {path: path.read_bytes() for path in paths} + assert normalized(cli(tmp_path, monkeypatch, operation)) == normalized(current) + assert normalized(clean(tmp_path, tmp_path / "independent-cache")) == normalized(current) + + +def test_req_qml011_ac04_removed_admission_removes_source_overlay(tmp_path, monkeypatch): + """Removing the last Qt marker removes derived links and matches a clean rebuild.""" + monkeypatch.chdir(tmp_path) + prior_products(tmp_path, monkeypatch) + before = cli(tmp_path, monkeypatch, "update") + retired = {identity for identity, data in before.nodes(data=True) if qt_metadata(data)} + assert retired + (tmp_path / "helper.cpp").write_text( + '#include "helper.hpp"\nvoid Helper::run(int count) { (void)count; }\n', encoding="utf-8") + current = cli(tmp_path, monkeypatch, "update") + assert not retired.intersection(current) + assert unchanged_python(before) == unchanged_python(current) + assert normalized(clean(tmp_path, tmp_path / "independent-cache")) == normalized(current) + assert normalized(cli(tmp_path, monkeypatch, "update")) == normalized(current) + + +@pytest.mark.parametrize("force", [False, True]) +def test_req_qml012_ac02_failed_source_links_upgrade_retains_products(tmp_path, monkeypatch, force): + """A malformed admitted implementation retains graph/stamp/root/manifest; retry recovers.""" + monkeypatch.chdir(tmp_path) + paths, _, _ = prior_products(tmp_path, monkeypatch) + out = tmp_path / "graphify-out" + products = [out / "graph.json", out / "manifest.json", out / QT_STATE_FILE, out / ".graphify_root"] + before = {path: path.read_bytes() for path in products} + paths[1].write_text("void Helper::run( { Q_UNUSED(count) }\n", encoding="utf-8") + with pytest.raises(SystemExit) as failure: + cli(tmp_path, monkeypatch, "update", force=force) + assert failure.value.code not in (0, None) + assert before == {path: path.read_bytes() for path in products} + paths[1].write_text(SOURCES["helper.cpp"], encoding="utf-8") + recovered = cli(tmp_path, monkeypatch, "update") + assert not inspect_qt_analysis(tmp_path, out, paths).changed + assert normalized(cli(tmp_path, monkeypatch, "update")) == normalized(recovered) diff --git a/tests/test_qt_source_state.py b/tests/test_qt_source_state.py new file mode 100644 index 0000000000..ddc756d3d1 --- /dev/null +++ b/tests/test_qt_source_state.py @@ -0,0 +1,47 @@ +"""Production source-state transitions cannot borrow or retain false Qt identity.""" +import json + +from graphify.extract import extract +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_analysis_helpers import analysis, sites +from tests.test_qt_signals_slots import SOURCE + + +def test_warm_source_results_match_and_reassigned_handle_loses_target(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = f'''void use() {{ QQuickView view; view.setSource(QUrl("{url}")); + auto root = view.rootObject(); root->property("value"); }}''' + sources = {"access.cpp": source, "Main.qml": 'import QtQml\nQtObject { property int value: 1 }'} + cold = analysis(tmp_path, sources) + warm = extract([tmp_path / name for name in sources], root=tmp_path, cache_root=tmp_path, parallel=False) + assert [(node["id"], qt_metadata(node)) for node in cold["nodes"] if qt_metadata(node)] == [(node["id"], qt_metadata(node)) for node in warm["nodes"] if qt_metadata(node)] + changed = analysis(tmp_path, {**sources, "access.cpp": source.replace('root->property', 'root = factory(); root->property')}) + access = sites(changed, "qml_access")[0] + assert qt_metadata(access)["status"] == "dynamic" + # Declaration identity now rejects the observed write before the older + # handle-assignment check; retain its more precise reason and no target. + assert qt_metadata(access)["reason"] == "reassigned_declaration" + assert qt_metadata(access)["receiver_declaration_id"] == "" + assert not [edge for edge in changed["edges"] if edge["source"] == access["id"] and edge["relation"] in {"uses", "calls"}] + + +def test_source_scopes_do_not_cross_between_functions_with_same_local_name(tmp_path): + url = (tmp_path / "Main.qml").as_uri() + source = f'''void first() {{ QQuickView view; view.setSource(QUrl("{url}")); auto root = view.rootObject(); }} +void second(QObject *root) {{ root->property("value"); }}''' + result = analysis(tmp_path, {"access.cpp": source, "Main.qml": 'import QtQml\nQtObject { property int value: 1 }'}) + assert qt_metadata(sites(result, "qml_access")[0])["status"] == "unavailable" + + +def test_fresh_connection_borrows_prior_header_facts_without_mutation(tmp_path): + header, wire = SOURCE.split('void wire', 1) + initial = analysis(tmp_path, {"events.h": header, "wire.cpp": 'void wire' + wire}) + borrowed_nodes = [node for node in initial["nodes"] if node["source_file"] == "events.h"] + borrowed_ids = {node["id"] for node in borrowed_nodes} + borrowed_edges = [edge for edge in initial["edges"] if edge["source"] in borrowed_ids and edge["target"] in borrowed_ids] + before = json.dumps([borrowed_nodes, borrowed_edges], sort_keys=True) + fresh = extract([tmp_path / "wire.cpp"], root=tmp_path, cache_root=tmp_path, parallel=False, + resolution_context_nodes=borrowed_nodes, resolution_context_edges=borrowed_edges) + assert not fresh.get("qml_failures") + assert all(qt_metadata(node)["status"] == "resolved" for node in sites(fresh, "connect")) + assert json.dumps([borrowed_nodes, borrowed_edges], sort_keys=True) == before diff --git a/tests/test_qt_typed_provider_adoption.py b/tests/test_qt_typed_provider_adoption.py new file mode 100644 index 0000000000..6eab032fd7 --- /dev/null +++ b/tests/test_qt_typed_provider_adoption.py @@ -0,0 +1,188 @@ +"""REQ-QML-018-AC03/AC04: declared factories and typed service paths.""" +from __future__ import annotations + +import pytest + +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.qt_analysis_helpers import analysis, sites +from tests.qt_adoption_fixture import NATIVE, sources +from tests.qt_adoption_fixture import QML + + +@pytest.mark.parametrize("expression", ["factory->makeBackend()", "factory->backend"]) +def test_req_qml018_ac03_declared_expression_selects_backend_api(tmp_path, expression): + """A typed return/field lends its declared API, never runtime allocation evidence.""" + result = analysis(tmp_path, sources(tmp_path, expression)) + bindings = sites(result, "context_binding") + assert len(bindings) == 1 + metadata = qt_metadata(bindings[0]) + assert metadata["provider_class_name"] == "Backend" + assert metadata["provider_declaration_id"] + assert metadata.get("provider_type_evidence") + + +@pytest.mark.parametrize("expression", ["backend", "factory->makeBackend()"]) +def test_req_qml018_ac04_typed_service_call_and_property_are_source_owned(tmp_path, expression): + """The child property's declared Service API owns refresh/count endpoints.""" + result = analysis(tmp_path, sources(tmp_path, expression)) + accesses = sites(result, "context_access") + by_id = {node["id"]: node for node in result["nodes"]} + targets = [by_id[qt_metadata(node)["target_id"]] for node in accesses] + proofs = [qt_metadata(node)["endpoint_proof"] for node in accesses] + assert any(qt_metadata(by_id[proof["member_fact_id"]]).get("raw_name") == "refresh" + and proof["canonical_target_id"] == qt_metadata(by_id[proof["member_fact_id"]])["generic_target_id"] + and by_id[proof["canonical_target_id"]].get("label") == ".refresh()" for proof in proofs) + assert any(qt_metadata(node).get("raw_name") == "count" for node in targets) + for access in accesses: + proof = qt_metadata(access).get("endpoint_proof", {}) + assert proof.get("evidence") and proof.get("canonical_target_id") + + +@pytest.mark.parametrize("expression", ["backend", "factory->makeBackend()"]) +def test_req_qml018_ac04_context_service_subscriptions_have_distinct_sites(tmp_path, expression): + """Connections and signal.connect preserve direction without delivery calls.""" + result = analysis(tmp_path, sources(tmp_path, expression)) + subscriptions = sites(result, "context_subscription") + assert len(subscriptions) == 2 + assert {qt_metadata(node).get("subscription_form") for node in subscriptions} == {"Connections", "signal.connect"} + assert all(qt_metadata(node)["status"] == "resolved" for node in subscriptions) + assert not [edge for edge in result["edges"] if edge.get("context") == "qt_context_subscription" and edge.get("relation") == "calls"] + + +@pytest.mark.parametrize("change", [ + lambda native: native.replace("Backend *makeBackend()", "Unknown *makeBackend()"), + lambda native: native.replace("Backend *makeBackend()", "Backend *makeBackend(int value)"), + lambda native: native.replace("public: Backend *makeBackend()", "private: Backend *makeBackend()"), + lambda native: native.replace("Backend *makeBackend() { throw 7; }", "Backend *makeBackend(); Service *makeBackend();"), +]) +def test_req_qml018_ac03_unknown_or_conflicting_factory_never_supplies_provider(tmp_path, change): + """Incomplete, overload, access and conflicting return evidence cannot guess a type.""" + result = analysis(tmp_path, sources(tmp_path, native=change(NATIVE))) + assert not sites(result, "context_binding") and not sites(result, "context_access") + + +@pytest.mark.parametrize("expression", ["factory->makeBackend(1)", "factory->missing", "factory->backend->missing()"]) +def test_req_qml018_ac03_unsupported_provider_expression_never_executes_or_guesses(tmp_path, expression): + """Unknown fields, arguments and undeclared intermediate methods remain opaque.""" + result = analysis(tmp_path, sources(tmp_path, expression)) + assert not sites(result, "context_binding") + + +def test_req_qml018_ac03_multiple_declared_expression_hops_lend_service_api(tmp_path): + """A public field followed by its public getter lends the final declared API.""" + result = analysis(tmp_path, sources(tmp_path, "factory->backend->service()")) + binding = sites(result, "context_binding")[0] + assert qt_metadata(binding)["provider_class_name"] == "Service" + + +def test_req_qml018_ac03_conditional_provider_declaration_is_not_api_authority(tmp_path): + """Accepted syntax in an unevaluated preprocessor branch cannot expose a provider.""" + native = "#if FEATURE\n" + NATIVE + "\n#endif\n" + assert not sites(analysis(tmp_path, sources(tmp_path, "backend", native=native)), "context_binding") + + +@pytest.mark.parametrize("mutation", ["reassign", "conditional", "private_field"]) +def test_req_qml018_ac03_root_identity_and_member_access_are_required(tmp_path, mutation): + """Declared return type cannot repair a changed lifetime, condition or private field.""" + inputs = sources(tmp_path, "factory->backend" if mutation == "private_field" else "factory->makeBackend()") + if mutation == "reassign": + inputs["access.cpp"] = inputs["access.cpp"].replace(" QQmlApplicationEngine", " factory = nullptr; QQmlApplicationEngine") + elif mutation == "conditional": + inputs["access.cpp"] = inputs["access.cpp"].replace("engine.rootContext()", "if (factory) engine.rootContext()") + else: + inputs["native.h"] = inputs["native.h"].replace(" Backend *backend;", "private: Backend *backend;") + assert not sites(analysis(tmp_path, inputs), "context_binding") + + +@pytest.mark.parametrize("change", [ + lambda text: text.replace("Q_PROPERTY(Service* service", "Q_PROPERTY(Unknown* service"), + lambda text: text.replace("Service *service()", "Unknown *service()"), + lambda text: text.replace("Q_PROPERTY(Service* service READ service)", + "#if FEATURE\n Q_PROPERTY(Service* service READ service)\n#endif"), + lambda text: text.replace("Q_INVOKABLE void refresh() {}", "Q_INVOKABLE void refresh() {} Q_INVOKABLE void refresh(int count) {}"), +]) +def test_req_qml018_ac04_unproven_child_types_or_overloads_do_not_select_terminal_api(tmp_path, change): + """Property and getter agree on one type; terminal overloads remain unresolved.""" + result = analysis(tmp_path, sources(tmp_path, native=change(NATIVE))) + assert sites(result, "context_binding") + assert not any(qt_metadata(node).get("endpoint_proof", {}).get("kind") == "function" + for node in sites(result, "context_access")) + + +def test_req_qml018_ac04_ordinary_method_is_not_a_signal_subscription(tmp_path): + """A same-name invokable does not lend Qt signal semantics to connect syntax.""" + result = analysis(tmp_path, sources(tmp_path, native=NATIVE.replace("signals: void ready();", "public: Q_INVOKABLE void ready();"))) + assert not sites(result, "context_subscription") + + +@pytest.mark.parametrize("mutation,expected", [("root", 1), ("callback", 1), ("own_property", 0)]) +def test_req_qml018_ac04_qml_lexical_and_property_shadows_precede_context_provider(tmp_path, mutation, expected): + """Parameter/callback scopes and own properties prevent provider fallback.""" + qml = QML.replace("function run()", "function run(backend)" if mutation == "root" else + "function run(handleReady)" if mutation == "callback" else "function run()") + if mutation == "own_property": + qml = qml.replace("QtObject {", "QtObject { property QtObject backend: QtObject {}", 1) + result = analysis(tmp_path, sources(tmp_path, qml=qml)) + assert len(sites(result, "context_subscription")) == expected + + +@pytest.mark.parametrize("style,expected", [("legacy", False), ("function", True), ("arrow", True)]) +def test_req_qml018_ac04_native_implicit_parameters_do_not_bind_explicit_handlers(tmp_path, style, expected): + """Only legacy blocks inherit native signal names; explicit formals stay local.""" + handler = {"legacy": "onReady: { count }", "function": "function onReady(value) { count }", + "arrow": "onReady: (value) => { count }"}[style] + qml = 'import QtQml\nQtObject { property int count: 2; property Connections sub: Connections {' + qml += 'target: backend.service; ' + handler + ' } }' + native = NATIVE.replace("void ready();", "void ready(int count);") + result = analysis(tmp_path, sources(tmp_path, qml=qml, native=native)) + assert len(sites(result, "context_subscription")) == 1 + reads = [node for node in result["nodes"] if node.get("metadata", {}).get("qml", {}).get("kind") == "read"] + from graphify.extractors.qml_facts import qml_metadata + count = next(node for node in reads if qml_metadata(node).get("reference") == "count") + actual = any(edge["source"] == count["id"] and edge.get("context") == "qml_binding_read" + for edge in result["edges"]) + assert actual is expected + + +@pytest.mark.parametrize("path", ["backend.service.parent.service.count", "backend." + "service." * 9 + "count"]) +def test_req_qml018_ac04_cyclic_and_oversized_child_paths_stay_unresolved(tmp_path, path): + """Cycle and chain limits cannot resolve by choosing an earlier convenient type.""" + native = "class Backend;\n" + NATIVE.replace(" Q_PROPERTY(int count READ count)", + " Q_PROPERTY(int count READ count) Q_PROPERTY(Backend* parent READ parent)") + native = native.replace("public: int count()", "public: Backend* parent() { throw 7; } int count()") + qml = "import QtQml\nQtObject { property int count: " + path + " }" + result = analysis(tmp_path, sources(tmp_path, qml=qml, native=native)) + assert not sites(result, "context_access") + + +@pytest.mark.parametrize("callback,expected", [("/* callback */ handleReady", 2), ("() => {}", 1), ("handleReady, 7", 1)]) +def test_req_qml018_ac04_callback_ast_comment_and_unsupported_forms_are_distinct(tmp_path, callback, expected): + """Comments preserve a literal callback; arbitrary expressions do not establish it.""" + qml = QML.replace("ready.connect(handleReady)", "ready.connect(" + callback + ")") + result = analysis(tmp_path, sources(tmp_path, qml=qml)) + assert len(sites(result, "context_subscription")) == expected + + +def test_req_qml018_ac04_child_property_changed_handler_uses_real_notify_signal(tmp_path): + """A context child property's change handler retains its differently named NOTIFY.""" + native = NATIVE.replace("Q_PROPERTY(int count READ count)", "Q_PROPERTY(int count READ count NOTIFY actualCount)") + native = native.replace("signals: void ready();", "signals: void ready(); void actualCount();") + qml = QML.replace("function onReady()", "function onCountChanged()") + result = analysis(tmp_path, sources(tmp_path, native=native, qml=qml)) + subscriptions = sites(result, "context_subscription") + assert len(subscriptions) == 2 + nodes = {node["id"]: node for node in result["nodes"]} + handler = next(node for node in subscriptions if qt_metadata(node)["subscription_form"] == "Connections") + proof = qt_metadata(handler)["endpoint_proof"] + assert qt_metadata(nodes[proof["member_fact_id"]])["raw_name"] == "actualCount" + + +def test_req_qml018_ac03_duplicate_typed_context_exposures_remain_ambiguous(tmp_path): + """Two independent exposures cannot choose a convenient provider occurrence.""" + inputs = sources(tmp_path) + fragment = 'engine.rootContext()->setContextProperty("backend", factory->makeBackend());' + inputs["access.cpp"] = inputs["access.cpp"].replace(fragment, fragment + fragment) + result = analysis(tmp_path, inputs) + bindings = sites(result, "context_binding") + assert len(bindings) == 2 and all(qt_metadata(node)["status"] == "ambiguous" for node in bindings) + assert not sites(result, "context_access") and not sites(result, "context_subscription") diff --git a/tests/test_qt_typed_provider_consumers.py b/tests/test_qt_typed_provider_consumers.py new file mode 100644 index 0000000000..4e75141276 --- /dev/null +++ b/tests/test_qt_typed_provider_consumers.py @@ -0,0 +1,73 @@ +"""REQ-QML-018-AC03/AC04/AC06: declared chains retain guarded consumer evidence.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import encode_qt, qt_metadata +from graphify.paths import load_node_link_graph +from graphify.qt_qml_projection import allows_qt_qml_edge +from tests.qt_analysis_helpers import analysis +from tests.qt_adoption_fixture import sources + + +def bridge_edges(result): + return [edge for edge in result["edges"] if edge.get("context") in + {"qt_context_member", "qt_context_subscription"}] + + +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml018_ac04_typed_subscriptions_survive_build_reload_and_query(tmp_path, directed): + """Producer direction reaches the native signal and QML handler through distinct sites.""" + result = analysis(tmp_path, sources(tmp_path)) + expected = bridge_edges(result) + assert len(expected) == 5 + graph = build_from_json(result, root=tmp_path, directed=directed) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path), force=True) + restored = load_node_link_graph(json.loads(path.read_text(encoding="utf-8"))) + for edge in expected: + assert restored.has_edge(edge["source"], edge["target"]) + metadata = restored.edges[edge["source"], edge["target"]] + assert qt_metadata(metadata)["endpoint_proof"] == qt_metadata(edge)["endpoint_proof"] + if not directed: + assert (metadata["_src"], metadata["_tgt"]) == (edge["source"], edge["target"]) + from graphify.serve import _query_graph_text + answer = _query_graph_text(restored, "native_service_ready", token_budget=8000) + assert "qt_context_subscription" in answer and "Main.qml" in answer and "references" in answer + + +@pytest.mark.parametrize("corruption", ["hop", "accessor", "provider", "class", "handler", "role", "transport"]) +def test_req_qml018_ac06_corrupted_endpoint_or_chain_is_rejected_at_consumer(tmp_path, corruption): + """A copied serialized bridge cannot lend authority to altered declarations or roles.""" + result = analysis(tmp_path, sources(tmp_path)) + nodes, edges = copy.deepcopy((result["nodes"], result["edges"])) + by_id = {node["id"]: node for node in nodes} + edge = next(edge for edge in edges if edge.get("context") == "qt_context_subscription") + source, target = by_id[edge["source"]], by_id[edge["target"]] + md = qt_metadata(source) + proof = qt_metadata(edge)["endpoint_proof"] + if corruption == "hop": + proof["type_chain"][0]["target_class_id"] = md["binding_id"] + fields = {key: value for key, value in qt_metadata(edge).items() if key != "raw_values"} + fields["endpoint_proof"] = proof + edge["metadata"]["qt"] = encode_qt(fields) + else: + chosen = (by_id[proof["type_chain"][0]["property_id"]] if corruption == "accessor" else + by_id[md["binding_id"]] if corruption == "provider" else + by_id[proof["class_id"]] if corruption == "class" else + by_id[proof["member_fact_id"]] if corruption in {"role", "transport"} else source) + if corruption == "class": + chosen["_callable_class"] = False + elif corruption == "transport": + chosen["metadata"]["qt"]["raw_values"]["raw_name"] = "invalid!" + else: + fields = {key: value for key, value in qt_metadata(chosen).items() if key != "raw_values"} + fields[{"accessor": "api_type_target_id", "provider": "provider_type_evidence", + "handler": "handler_target_id", "role": "roles"}[corruption]] = [] if corruption in {"provider", "role"} else "missing" + chosen["metadata"]["qt"] = encode_qt(fields) + assert not allows_qt_qml_edge(source, target, edge, source_id=source["id"], target_id=target["id"], nodes=by_id) diff --git a/tests/test_qt_typed_provider_updates.py b/tests/test_qt_typed_provider_updates.py new file mode 100644 index 0000000000..8aa1ca3c07 --- /dev/null +++ b/tests/test_qt_typed_provider_updates.py @@ -0,0 +1,171 @@ +"""REQ-QML-018-AC03--AC06: typed providers through real accepted updates.""" +from __future__ import annotations + +import pytest + +from graphify.cache import cache_dir +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.watch import _rebuild_code +from tests.qt_adoption_fixture import sources +from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated + + +PRODUCTS = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root") + + +def corpus(root): + """A public qmake module supplies the engine load; declaration facts supply APIs.""" + files = sources(root) + beginning = files["access.cpp"].split('engine.load(QUrl("')[0] + files["access.cpp"] = beginning + 'engine.loadFromModule("Public.Tools", "Main"); }\n' + files["tools.pro"] = 'QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.0\n' + files["tools.pro"] += 'HEADERS += $$PWD/native.h\nQML_FILES += $$PWD/Main.qml\n' + files["keep.py"] = 'def helper(): return 7\n\ndef retained(): return helper()\n' + for name, text in files.items(): + (root / name).write_bytes(text.encode()) + + +def facts(graph, kind): + return {identity: qt_metadata(node) for identity, node in graph.nodes(data=True) + if qt_metadata(node).get("kind") == kind} + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac05_cpp_qml_metadata_and_signal_updates_match_clean_rebuild(tmp_path, monkeypatch, operation): + """Real provider/property/signal and metadata mutations retire stale accepted sites.""" + corpus(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + assert len(facts(initial, "context_subscription")) == 2 + assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold")) + assert normalized(clean(tmp_path, tmp_path / ".cold")) == normalized(initial) + preserved = unrelated(initial) + header, qml, build = (tmp_path / name for name in ("native.h", "Main.qml", "tools.pro")) + original_header, original_qml, original_build = header.read_bytes(), qml.read_bytes(), build.read_bytes() + + # C++-only return-type loss rejects the factory without a name-based fallback. + header.write_bytes(original_header.replace(b"Backend *makeBackend()", b"Unknown *makeBackend()")) + rejected = run(tmp_path, monkeypatch, operation, [header]) + assert not facts(rejected, "context_binding") + assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".unknown-return")) + header.write_bytes(original_header) + assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(initial) + + # A declared child API rename invalidates unchanged QML service paths. + header.write_bytes(original_header.replace(b"Service* service READ service", b"Service* child READ service")) + no_child = run(tmp_path, monkeypatch, operation, [header]) + assert not facts(no_child, "context_subscription") + assert normalized(no_child) == normalized(clean(tmp_path, tmp_path / ".child-rename")) + qml.write_bytes(original_qml.replace(b"backend.service", b"backend.child")) + rebound = run(tmp_path, monkeypatch, operation, [qml]) + assert len(facts(rebound, "context_subscription")) == 2 + assert normalized(rebound) == normalized(clean(tmp_path, tmp_path / ".qml-rebind")) + + # Both subscription forms lose a removed signal, retaining ordinary calls. + header.write_bytes(header.read_bytes().replace(b"signals: void ready();", b"")) + no_signal = run(tmp_path, monkeypatch, operation, [header]) + assert not facts(no_signal, "context_subscription") + assert facts(no_signal, "context_access") + assert normalized(no_signal) == normalized(clean(tmp_path, tmp_path / ".signal-removed")) + header.write_bytes(original_header) + qml.write_bytes(original_qml) + assert normalized(run(tmp_path, monkeypatch, operation, [header, qml])) == normalized(initial) + + # Metadata-only URI changes invalidate the source-established loaded scope. + build.write_bytes(original_build.replace(b"Public.Tools", b"Public.Other")) + no_component = run(tmp_path, monkeypatch, operation, [build]) + assert not facts(no_component, "context_binding") + assert normalized(no_component) == normalized(clean(tmp_path, tmp_path / ".module-removed")) + build.write_bytes(original_build) + final = run(tmp_path, monkeypatch, operation, [build]) + assert normalized(final) == normalized(initial) and unrelated(final) == preserved + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(final) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("failure", ["cpp", "qml", "resolver"]) +def test_req_qml018_ac06_parser_and_resolver_failure_preserve_then_recover(tmp_path, monkeypatch, operation, failure): + """A real source parse failure or owning resolver exception retains committed products.""" + corpus(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + output = tmp_path / "graphify-out" + before = {name: (output / name).read_bytes() for name in PRODUCTS} + cache = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*") if path.is_file()} + path = tmp_path / ("native.h" if failure in {"cpp", "resolver"} else "Main.qml") + valid = path.read_bytes() + path.write_bytes(valid + (b"class Broken {\n" if failure == "cpp" else b"Item {\n" if failure == "qml" else b"\n// changed\n")) + with monkeypatch.context() as broken: + if failure == "resolver": + from graphify.qt_declared_provider import DeclaredProviderIndex + original = DeclaredProviderIndex.provider + def reject(self, metadata): + original(self, metadata) + raise ValueError("QT_METADATA: public test transport corruption") + broken.setattr(DeclaredProviderIndex, "provider", reject) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(tmp_path, broken, operation, [path]) + assert rejected.value.code == 1 + else: + assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True) + assert before == {name: (output / name).read_bytes() for name in PRODUCTS} + assert cache and all(path.read_bytes() == data for path, data in cache.items()) + path.write_bytes(valid) + corrected = run(tmp_path, monkeypatch, operation, [path]) + assert normalized(corrected) == normalized(initial) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("shape", ["return", "child", "root"]) +def test_req_qml018_ac03_ac04_declarator_shape_edit_retires_and_repairs_api(tmp_path, monkeypatch, operation, shape): + """An accepted syntax edit cannot retain stale one-object API links after shape changes.""" + corpus(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + assert len(facts(initial, "context_subscription")) == 2 + path = tmp_path / ("access.cpp" if shape == "root" else "native.h") + original = path.read_bytes() + if shape == "return": + changed = original.replace(b"Backend *makeBackend()", b"Backend **makeBackend()") + elif shape == "child": + changed = original.replace(b"Service* service", b"Service** service").replace(b"Service *service()", b"Service **service()") + else: + changed = original.replace(b"Factory *factory", b"Factory **factory") + path.write_bytes(changed) + rejected = run(tmp_path, monkeypatch, operation, [path]) + assert not facts(rejected, "context_subscription") + assert bool(facts(rejected, "context_binding")) is (shape == "child") + assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".shape-rebuild")) + path.write_bytes(original) + assert normalized(run(tmp_path, monkeypatch, operation, [path])) == normalized(initial) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("parameter", ["backend", "handleReady"]) +def test_req_qml018_ac04_native_parameter_edit_retires_nested_subscription_and_repairs(tmp_path, monkeypatch, operation, parameter): + """Unchanged cached QML is rebound after C++ alone adds/removes a legacy signal parameter.""" + corpus(tmp_path) + qml = tmp_path / "Main.qml" + qml.write_text('import QtQml\nQtObject { function handleReady() {} property Connections sub: Connections {' + ' target: backend.service; onReady: { backend.service.ready.connect(handleReady) } } }', encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + assert len(facts(initial, "context_subscription")) == 2 + header = tmp_path / "native.h" + original = header.read_bytes() + header.write_bytes(original.replace(b"void ready();", ("void ready(int " + parameter + ");").encode())) + shadowed = run(tmp_path, monkeypatch, operation, [header]) + assert len(facts(shadowed, "context_subscription")) == 1 + assert len(facts(shadowed, "context_handler_endpoint")) == 1 + assert normalized(shadowed) == normalized(clean(tmp_path, tmp_path / ".implicit-rebuild")) + header.write_bytes(original) + restored = run(tmp_path, monkeypatch, operation, [header]) + assert normalized(restored) == normalized(initial) + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial) diff --git a/tests/test_qt_update_direction_integrity.py b/tests/test_qt_update_direction_integrity.py new file mode 100644 index 0000000000..3b8d3066b7 --- /dev/null +++ b/tests/test_qt_update_direction_integrity.py @@ -0,0 +1,161 @@ +"""INC-QML-34: update shortcuts and raw writers reject corrupt logical pairs.""" +from __future__ import annotations + +import copy +import json +import os + +import networkx as nx +import pytest + +import graphify.__main__ as entrypoint +import graphify.build as builder +import graphify.export as export +import graphify.watch as watch +from graphify.cache import cache_dir +from graphify.paths import load_node_link_graph +from tests.test_qt_json_direction import damage + + +def update(root, monkeypatch, operation, *, raw, force=False): + """An unchanged full refresh reaches comparison rather than the empty-batch shortcut.""" + if operation == "manual": + monkeypatch.setattr(entrypoint, "_check_skill_version", lambda *_: None) + monkeypatch.setattr(entrypoint, "_refresh_stale_skills", lambda: None) + arguments = ["graphify", "update", str(root)] + if raw: + arguments.append("--no-cluster") + if force: + arguments.append("--force") + monkeypatch.setattr(entrypoint.sys, "argv", arguments) + entrypoint.main() + return True + return watch._rebuild_code(root, no_cluster=raw, force=force) + + +def snapshot(root, *, pin=False): + output = root / "graphify-out" + names = ("graph.json", ".graphify_root", "manifest.json", ".qt_analysis.json", "GRAPH_REPORT.md", + ".graphify_labels.json", ".graphify_labels.json.sig") + result = {} + for name in names: + path = output / name + if path.is_file(): + if pin: + before = path.stat() + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns - 10_000_000_000)) + result[name] = path.read_bytes(), path.stat().st_mtime_ns + return result + + +def project(root, monkeypatch, operation, *, raw, directed=False): + """Actual source-backed containment and QML reads supply both producer paths.""" + (root / "Main.qml").write_bytes(b"import QtQml\nQtObject { property int input: 1; property int output: input }") + (root / "keep.py").write_bytes(b"def retained(): return 7\n") + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + assert update(root, monkeypatch, operation, raw=raw) + if directed: + # Directed updates inherit this public persisted profile. Serialized + # endpoints are already their accepted logical pair in the real output. + path = root / "graphify-out/graph.json" + payload = json.loads(path.read_bytes()) + payload["directed"] = True + path.write_text(json.dumps(payload), encoding="utf-8") + assert update(root, monkeypatch, operation, raw=raw) + assert load_node_link_graph(root / "graphify-out/graph.json").is_directed() == directed + + +def fault_actual_pair(monkeypatch, *, raw, corruption): + """Fault only the actual builder/deduper output; never fake extraction or validation.""" + observed: dict = {"json_calls": 0} + original_json = export.to_json + + def counted_json(*args, **kwargs): + observed["json_calls"] += 1 + return original_json(*args, **kwargs) + + monkeypatch.setattr(export, "to_json", counted_json) + if raw: + original = builder.dedupe_edges + + def malformed_raw(edges): + result = original(edges) + edge = next(data for data in result if data.get("relation") == "contains") + damage(edge, corruption, edge["source"], edge["target"]) + observed["before"] = copy.deepcopy(result) + observed["output"] = result + return result + + monkeypatch.setattr(builder, "dedupe_edges", malformed_raw) + else: + original = builder.build_from_json + + def malformed_graph(*args, **kwargs): + graph = original(*args, **kwargs) + source, target, edge = next((u, v, data) for u, v, data in graph.edges(data=True) + if data.get("relation") == "contains" + and (data.get("_src"), data.get("_tgt")) == (u, v)) + damage(edge, corruption, source, target) + observed["before"] = copy.deepcopy(nx.node_link_data(graph, edges="links")) + observed["output"] = graph + return graph + + monkeypatch.setattr(builder, "build_from_json", malformed_graph) + return observed + + +def assert_rejected(root, monkeypatch, operation, *, raw, force): + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + update(root, monkeypatch, operation, raw=raw, force=force) + assert rejected.value.code == 1 + else: + assert not update(root, monkeypatch, operation, raw=raw, force=force) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("raw", [False, True]) +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("corruption", ["partial_source", "partial_target", "foreign_source", "foreign_target", + "list", "null", "collapsed", "reversed"]) +def test_actual_corrupt_candidate_refuses_shortcut_publication_and_repairs(tmp_path, monkeypatch, capsys, + operation, raw, force, corruption): + """Force cannot grant invalid transport; the old cohort and cache survive refusal then a corrected retry.""" + project(tmp_path, monkeypatch, operation, raw=raw, directed=corruption == "reversed") + prior = snapshot(tmp_path, pin=True) + cached = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")} + assert cached + with monkeypatch.context() as fault: + observed = fault_actual_pair(fault, raw=raw, corruption=corruption) + assert_rejected(tmp_path, monkeypatch, operation, raw=raw, force=force) + assert observed["json_calls"] == 0 + unchanged = observed["output"] if raw else nx.node_link_data(observed["output"], edges="links") + assert unchanged == observed["before"] + assert snapshot(tmp_path) == prior and all(path.read_bytes() == data for path, data in cached.items()) + diagnostic = capsys.readouterr().err + assert "QT_EXPORT_DIRECTION" in diagnostic + assert "private-needle" not in diagnostic and "forged diagnostic" not in diagnostic + output = tmp_path / "graphify-out" + assert not (output / ".graph.tmp.json").exists() and not list(output.glob(".gfy-publish-*")) + assert update(tmp_path, monkeypatch, operation, raw=raw, force=force) + assert snapshot(tmp_path) == prior + assert update(tmp_path, monkeypatch, operation, raw=raw, force=force) + assert snapshot(tmp_path) == prior + # A real source change remains publishable after rejection and does not + # become an idempotence shortcut because only run metadata was normalized. + source = tmp_path / "Main.qml" + source.write_bytes(source.read_bytes().replace(b"input", b"adjustment")) + assert update(tmp_path, monkeypatch, operation, raw=raw, force=force) + assert snapshot(tmp_path)["graph.json"][0] != prior["graph.json"][0] + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("raw", [False, True]) +def test_complete_reverse_pair_remains_accepted_on_undirected_storage(tmp_path, monkeypatch, operation, raw): + """A full reversal is permitted for undirected storage; only invalid pairs are refused.""" + project(tmp_path, monkeypatch, operation, raw=raw) + with monkeypatch.context() as fault: + fault_actual_pair(fault, raw=raw, corruption="reversed") + assert update(tmp_path, monkeypatch, operation, raw=raw) + assert (tmp_path / "graphify-out/graph.json").is_file() diff --git a/tests/test_qt_update_discovery_profile.py b/tests/test_qt_update_discovery_profile.py new file mode 100644 index 0000000000..847798ee1f --- /dev/null +++ b/tests/test_qt_update_discovery_profile.py @@ -0,0 +1,92 @@ +"""INC-QML-46: real update CLI discovery profiles and pre-publication rejection.""" +from __future__ import annotations + +import pytest + +import graphify.__main__ as entrypoint +import graphify.watch as watch +from tests.test_qt_final_incremental_parity import manual_update, normalized, project, published +from tests.test_qt_product_publication import snapshot +from tests.test_source_alias_provenance import directory_alias # noqa: F401 +from tests.test_watch_physical_coowners import cache_bytes + + +def test_req_qml011_ac01_ac04_cli_default_and_optin_reach_real_publication(tmp_path, monkeypatch): + """The parser forwards each profile through actual nested rebuilds without changing default facts.""" + root = tmp_path.resolve() + project(root) + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + observed, real_rebuild = [], watch._rebuild_code + + def rebuild(*args, **kwargs): + observed.append(kwargs.get("follow_symlinks", False)) + return real_rebuild(*args, **kwargs) + + monkeypatch.setattr(watch, "_rebuild_code", rebuild) + manual_update(root, monkeypatch) + before, graph = snapshot(root / "graphify-out"), normalized(published(root)) + assert observed and set(observed) == {False} + observed.clear() + manual_update(root, monkeypatch, follow_symlinks=True) + assert observed and set(observed) == {True} + assert normalized(published(root)) == graph and snapshot(root / "graphify-out") == before + + +@pytest.mark.parametrize("arguments,message", [ + (["root", "--not-supported", "--follow-symlinks"], "unknown update option"), + (["--follow-symlinks", "root", "--not-supported"], "unknown update option"), + (["root", "--follow-symlinks", "other"], "at most one path"), + (["--follow-symlinks", "root", "other"], "at most one path"), +]) +def test_req_qml011_ac03_invalid_cli_profile_refuses_before_rebuild_or_publication( + tmp_path, monkeypatch, capsys, arguments, message): + """Invalid flags or multiple roots retain a real accepted cohort and cache before any rebuild.""" + root = tmp_path.resolve() + project(root) + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + manual_update(root, monkeypatch) + output = root / "graphify-out" + before, cached = snapshot(output), cache_bytes(output) + assert cached + + def forbidden(*args, **kwargs): + raise AssertionError("invalid parser input reached rebuild") + + monkeypatch.setattr(watch, "_rebuild_code", forbidden) + argv = [str(root) if item == "root" else str(root / "other") if item == "other" else item + for item in arguments] + monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "update", *argv]) + capsys.readouterr() + with pytest.raises(SystemExit) as rejected: + entrypoint.main() + assert rejected.value.code == 2 and message in capsys.readouterr().err + assert snapshot(output) == before and cache_bytes(output) == cached + + +def test_req_qml011_ac01_optin_cannot_discover_foreign_qt_provider( + tmp_path, directory_alias, monkeypatch): + """Following an external alias cannot add an ambiguous native provider or alter accepted facts.""" + root, foreign = tmp_path / "repo", tmp_path / "foreign" + root.mkdir() + foreign.mkdir() + project(root) + (foreign / "Foreign.h").write_text('class Foreign : public QObject { Q_OBJECT ' + 'QML_NAMED_ELEMENT(Service) public: Q_INVOKABLE void stolen(); };\n', encoding="utf-8") + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + manual_update(root, monkeypatch) + before, graph = snapshot(root / "graphify-out"), normalized(published(root)) + directory_alias(root / "outside", foreign) + manual_update(root, monkeypatch, follow_symlinks=True) + assert normalized(published(root)) == graph and snapshot(root / "graphify-out") == before + assert (foreign / "Foreign.h").exists() + + +def test_req_qml011_ac01_cli_help_exposes_supported_optin(monkeypatch, capsys): + """Actual help makes the supported discovery option and unchanged default visible.""" + monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "--help"]) + entrypoint.main() + section = capsys.readouterr().out.split("update ", 1)[1].split("cluster-only", 1)[0] + assert "--follow-symlinks" in section and "default: off" in section diff --git a/tests/test_qt_worker_cache_integrity.py b/tests/test_qt_worker_cache_integrity.py new file mode 100644 index 0000000000..9ed471d3a6 --- /dev/null +++ b/tests/test_qt_worker_cache_integrity.py @@ -0,0 +1,115 @@ +"""Actual workers, parser cache retention and native scope-root publication.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +import graphify.extract as extraction +from graphify.cache import save_cached +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.qt_analysis_state import inspect_qt_analysis +from graphify.watch import _rebuild_code +from tests.qml_test_helpers import canonical +from tests.test_qt_config_incremental import _project, _snapshot, _write + + +CPP = """#include +class Backend : public QObject { + Q_OBJECT +public: void tick() { emit ready(1); } +signals: void ready(int value); +}; +""" + + +def _provider(result): + nodes = {node["id"]: node for node in result["nodes"]} + sites = [node for node in nodes.values() if node["source_file"] == "Main.qml" and + qml_metadata(node).get("kind") == "type_use"] + assert len(sites) == 1 and qml_metadata(sites[0])["status"] == "resolved" + links = [edge for edge in result["edges"] if edge["source"] == sites[0]["id"] and edge["relation"] == "uses"] + assert len(links) == 1 + return nodes[links[0]["target"]]["source_file"] + + +@pytest.mark.parametrize("roots,expected", [ + ('["first", "second"]', "first/Public/Tools/Service.qml"), + ('["second", "first"]', "second/Public/Tools/Service.qml"), +]) +def test_qml010_ac01_actual_workers_preserve_configured_roots_script_and_native_facts(tmp_path, monkeypatch, roots, expected): + _project(tmp_path) + _write(tmp_path, "Main.qml", 'import Public.Tools 1.0\nimport "helpers.js" as Tools\nService { property int displayed: Tools.compute(value) }\n') + _write(tmp_path, "helpers.js", ".pragma library\nfunction compute(value) { return value * 2; }\n") + _write(tmp_path, "backend.cpp", CPP) + for index in range(18): + _write(tmp_path, f"panels/Panel{index}.qml", f"QtObject {{ property int value: {index} }}\n") + paths = extraction.collect_files(tmp_path, root=tmp_path) + monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", roots) + analysis = inspect_qt_analysis(tmp_path, tmp_path / "state-output", paths) + ran, real_pool = [], extraction._extract_parallel + + def observe_pool(*args, **kwargs): + succeeded = real_pool(*args, **kwargs) + ran.append(succeeded) + return succeeded + + monkeypatch.setattr(extraction, "_extract_parallel", observe_pool) + parallel = extraction.extract(paths, root=tmp_path, cache_root=tmp_path / "pool-cache", + qml_import_roots=analysis.import_roots, parallel=True, max_workers=2) + assert ran == [True] + sequential = extraction.extract(list(reversed(paths)), root=tmp_path, cache_root=tmp_path / "serial-cache", + qml_import_roots=analysis.import_roots, parallel=False) + warm = extraction.extract(paths, root=tmp_path, cache_root=tmp_path / "serial-cache", + qml_import_roots=analysis.import_roots, parallel=False) + assert not parallel["qml_failures"] and not parallel["failed_sources"] + assert _provider(parallel) == expected + assert any(edge.get("context") == "qml_script_call" for edge in parallel["edges"]) + assert any(qt_metadata(node).get("kind") == "emission" for node in parallel["nodes"]) + assert canonical(parallel) == canonical(sequential) == canonical(warm) + + +def _ast_snapshot(root): + base = root / "graphify-out/cache/ast" + return {path.relative_to(base).as_posix(): path.read_bytes() for path in base.rglob("*.json")} + + +@pytest.mark.parametrize("force", [False, True]) +def test_qml012_ac02_failed_native_parser_keeps_prior_real_ast_cache_and_products(tmp_path, force): + source = _write(tmp_path, "backend.cpp", CPP) + # Model an existing production cache from before native compatibility bypass; + # use the real generic extractor and cache serializer, not arbitrary bytes. + syntax = extraction.extract_cpp(source) + assert syntax["nodes"] + save_cached(source, syntax, root=tmp_path, cache_root=tmp_path) + cache_before = _ast_snapshot(tmp_path) + assert cache_before + assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False) + assert _ast_snapshot(tmp_path) == cache_before + products_before = _snapshot(tmp_path) + source.write_text("class Backend : public QObject { Q_OBJECT public: void tick( { }", encoding="utf-8") + assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True, + acquire_lock=False, force=force) + assert _ast_snapshot(tmp_path) == cache_before + assert _snapshot(tmp_path) == products_before + source.write_text(CPP, encoding="utf-8") + assert _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True, acquire_lock=False) + assert _ast_snapshot(tmp_path) == cache_before + + +@pytest.mark.parametrize("native", [True, False], ids=["qt-scoped-source", "ordinary-cpp"]) +def test_qml010_ac02_cpp_subfolder_rebase_rejects_only_native_scoped_facts(tmp_path, monkeypatch, capsys, native): + project = tmp_path / "project" + project.mkdir() + _write(project, "backend.cpp", CPP if native else "int add(int value) { return value + 1; }\n") + assert _rebuild_code(project, no_cluster=True, acquire_lock=False) + before = _snapshot(project) + monkeypatch.chdir(tmp_path) + succeeded = _rebuild_code(Path("project"), force=True, no_cluster=True, acquire_lock=False) + if native: + assert not succeeded + assert _snapshot(project) == before + assert "QML_ROOT_MISMATCH" in capsys.readouterr().out + else: + assert succeeded diff --git a/tests/test_shell_portability_helpers.py b/tests/test_shell_portability_helpers.py new file mode 100644 index 0000000000..74427bc4bf --- /dev/null +++ b/tests/test_shell_portability_helpers.py @@ -0,0 +1,161 @@ +"""Characterize exact file identity at the shell/native fixture boundary.""" +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +from tests.shell_portability import resolve_shell_path, select_shell_executable + + +def test_native_path_keeps_full_identity_with_spaces(tmp_path): + """A full native path identifies the created file, including spaced parents.""" + target = tmp_path / "tool with spaces" / "python" + target.parent.mkdir() + target.touch() + assert resolve_shell_path(str(target)) == target.resolve() + + +def test_same_named_sibling_is_not_the_requested_file(tmp_path): + """Canonicalization must preserve parent identity rather than just basename.""" + requested = tmp_path / "requested" / "python" + rejected = tmp_path / "rejected" / "python" + for target in (requested, rejected): + target.parent.mkdir() + target.touch() + assert resolve_shell_path(str(rejected)) != requested.resolve() + + +def test_relative_identity_is_rejected(tmp_path, monkeypatch): + """An existing relative file cannot accidentally bind to the test runner cwd.""" + (tmp_path / "python").touch() + monkeypatch.chdir(tmp_path) + with pytest.raises(AssertionError, match="relative file identity"): + resolve_shell_path("python") + + +def test_missing_absolute_identity_is_rejected(tmp_path): + """A nonexistent interpreter candidate cannot satisfy a path-only comparison.""" + with pytest.raises(FileNotFoundError): + resolve_shell_path(str(tmp_path / "missing-python")) + + +@pytest.mark.parametrize("shell", ["bash", "sh"]) +def test_req_core004_ac03_missing_shell_selection_is_rejected(shell, tmp_path, monkeypatch): + """An empty or unavailable configured PATH fails before any process launch.""" + for configured in ("", str(tmp_path)): + monkeypatch.setenv("PATH", configured) + with pytest.raises(AssertionError, match="unavailable in configured PATH"): + select_shell_executable(shell) + + +@pytest.mark.parametrize("selection", ["relative", "directory", "missing"]) +def test_req_core004_ac03_invalid_shell_identity_is_rejected(selection, tmp_path, monkeypatch): + """Lookup transport cannot admit relative, directory, or nonexistent identities.""" + target = tmp_path / "shell" + target.mkdir() if selection == "directory" else None + supplied = "shell" if selection == "relative" else str(target) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.setattr(shutil, "which", lambda candidate: supplied) + with pytest.raises(AssertionError, match="absolute executable file"): + select_shell_executable("bash") + + +def test_req_core004_ac03_unadmitted_shell_name_is_rejected(): + """Shell fixture admission cannot become a generic executable launcher.""" + with pytest.raises(AssertionError, match="only Bash/sh"): + select_shell_executable("cmd") + + +@pytest.mark.parametrize("shell", ["bash", "sh"]) +def test_req_core004_ac02_admitted_shell_runs_real_posix_command(shell): + """The selected absolute Bash/sh executes POSIX syntax without a GNU-only probe.""" + if shutil.which(shell) is None: + pytest.skip(f"{shell} is unavailable for executable identity proof") + selected = select_shell_executable(shell) + assert Path(selected).is_absolute() and Path(selected).is_file() + result = subprocess.run([selected, "-c", "printf %s admitted-shell"], + capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + assert result.stdout == "admitted-shell" + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows executable search boundary") +@pytest.mark.parametrize("shell", ["bash", "sh"]) +def test_req_core004_ac02_native_cwd_shadow_requires_explicit_path_admission( + shell, tmp_path, monkeypatch, +): + """A real native CWD executable shadows bare launch but never an admitted PATH.""" + selected = select_shell_executable(shell) + # The harmless system lookup binary is a real competing native executable; + # its stdout proves which process ran without launching WSL or corpus code. + shadow = tmp_path / f"{shell}.exe" + shutil.copyfile(Path(os.environ["SystemRoot"]) / "System32" / "where.exe", shadow) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PATH", str(Path(selected).parent)) + bare = subprocess.run([shell, shadow.name], capture_output=True, text=True, timeout=15) + assert bare.returncode == 0 and str(shadow) in bare.stdout + assert select_shell_executable(shell) == selected + actual = subprocess.run([select_shell_executable(shell), "-c", "printf %s admitted-shell"], + capture_output=True, text=True, timeout=15) + assert actual.returncode == 0 and actual.stdout == "admitted-shell", actual.stderr + # CWD is eligible only when it is itself an explicit configured PATH entry. + for entry in (".", ""): + monkeypatch.setenv("PATH", entry + os.pathsep + str(Path(selected).parent)) + assert select_shell_executable(shell) == str(shadow.resolve()) + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows executable search boundary") +@pytest.mark.parametrize("consumer", ["hook_bash", "hook_sh", "skillgen_bash"]) +def test_req_core004_ac02_real_consumers_resist_native_cwd_shadow( + consumer, tmp_path, monkeypatch, +): + """Actual hook/skill launchers use the admitted shell despite a CWD native decoy.""" + from tests.test_hooks import _broken_uv_machine, _detect_run, _shell_verdict, _tool_venv + from tests.test_skillgen_input_path_injection import _run_step1 + + shell = "sh" if consumer == "hook_sh" else "bash" + selected = select_shell_executable(shell) + shadow = tmp_path / f"{shell}.exe" + shutil.copyfile(Path(os.environ["SystemRoot"]) / "System32" / "where.exe", shadow) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PATH", str(Path(selected).parent)) + if consumer == "hook_bash": + assert _shell_verdict("*", "literal-payload", tmp_path) == "REJECTED" + elif consumer == "hook_sh": + home, stubs = _broken_uv_machine(tmp_path) + mine = _tool_venv(home, "graphifyy", "bin/python", ok=True) + result = _detect_run(tmp_path, home, stubs) + assert result.returncode == 0 and result.stdout.startswith("RESOLVED="), result.stderr + assert resolve_shell_path(result.stdout.removeprefix("RESOLVED=").strip()) == mine.resolve() + else: + result = _run_step1("printf %s admitted-shell\n# INPUT_PATH\n", "literal", tmp_path) + assert result.returncode == 0 and result.stdout == "admitted-shell", result.stderr + + +@pytest.mark.skipif(os.name != "nt" or shutil.which("sh") is None, + reason="native Windows and an MSYS shell required") +def test_msys_mount_mapping_preserves_full_sibling_identity(tmp_path): + """Real cygpath maps /tmp and spaced parents while keeping decoys distinct.""" + executable = select_shell_executable("sh") + system = subprocess.check_output([executable, "-c", "uname -s"], text=True).strip() + if not system.startswith(("MSYS_", "MINGW", "CYGWIN_")): + pytest.skip("the available Windows shell uses a different path namespace") + mapper = Path(executable).with_name("cygpath.exe") + paths = [] + for directory in ("requested with spaces", "rejected with spaces"): + target = tmp_path / directory / "python" + target.parent.mkdir() + target.touch() + reported = subprocess.check_output( + [str(mapper), "-u", "--", str(target)], text=True, + ).strip() + assert reported.startswith("/"), reported + paths.append((target, reported)) + requested, rejected = paths + assert resolve_shell_path(requested[1]) == requested[0].resolve() + assert resolve_shell_path(rejected[1]) == rejected[0].resolve() + assert resolve_shell_path(rejected[1]) != requested[0].resolve() diff --git a/tests/test_skill_auto_refresh.py b/tests/test_skill_auto_refresh.py index 3ac809cae3..40749178bc 100644 --- a/tests/test_skill_auto_refresh.py +++ b/tests/test_skill_auto_refresh.py @@ -54,15 +54,24 @@ def _stamp(dst: Path) -> str: # What gets refreshed # --------------------------------------------------------------------------- -def test_every_stale_platform_is_refreshed_not_only_the_detected_one(capsys): +@pytest.mark.parametrize("system", ["Linux", "Windows"]) +def test_every_stale_platform_is_refreshed_not_only_the_detected_one(capsys, monkeypatch, system): + """Refresh independent destinations; retain the ambiguous Windows shared copy.""" + monkeypatch.setattr(mainmod.platform, "system", lambda: system) dsts = {name: _stale(name) for name in ("claude", "codex", "opencode", "gemini")} mainmod._refresh_stale_skills() out, err = capsys.readouterr() for name, dst in dsts.items(): + if name == "gemini" and system == "Windows": + assert _stamp(dst) == OLD, name + assert dst.read_text(encoding="utf-8") == OLD_BODY, name + assert str(dst.parent) not in err + continue assert _stamp(dst) == __version__, name - assert dst.read_bytes() == _packaged(name), name + variant = "windows" if name == "claude" and system == "Windows" else name + assert dst.read_bytes() == _packaged(variant), name assert str(dst.parent) in err assert OLD in err and "GRAPHIFY_NO_AUTO_REFRESH" in err assert out == "", "stdout must stay clean for --json and the MCP stdio server" @@ -451,9 +460,11 @@ def test_uninstall_after_a_refresh_leaves_no_directory_behind(): assert not dst.parent.exists() -def test_a_stale_gemini_skill_gets_the_warning_too(monkeypatch, capsys): +@pytest.mark.parametrize("system", ["Linux", "Windows"]) +def test_a_stale_gemini_skill_gets_the_warning_too(monkeypatch, capsys, system): """gemini is not in _PLATFORM_CONFIG, so the version check never saw its - ~/.gemini copy; with the refresh off it must still warn.""" + user copy; with the refresh off it must still warn at its host destination.""" + monkeypatch.setattr(mainmod.platform, "system", lambda: system) monkeypatch.setenv("GRAPHIFY_NO_AUTO_REFRESH", "1") dst = _stale("gemini") monkeypatch.setattr(sys, "argv", ["graphify", "--version"]) @@ -462,7 +473,10 @@ def test_a_stale_gemini_skill_gets_the_warning_too(monkeypatch, capsys): err = capsys.readouterr().err assert f"warning: skill at {dst.parent} is from graphify {OLD}" in err - assert "graphify install --platform gemini" in err + # On Windows this is also agents' destination. The warning names the first + # matching installer; the stamp cannot identify the shared copy's variant. + platform_name = "agents" if system == "Windows" else "gemini" + assert f"graphify install --platform {platform_name}" in err def test_the_first_cli_run_after_an_upgrade_refreshes_and_stays_quiet(monkeypatch, capsys): diff --git a/tests/test_skillgen_input_path_injection.py b/tests/test_skillgen_input_path_injection.py index 752b5d3bf1..d4797f2be8 100644 --- a/tests/test_skillgen_input_path_injection.py +++ b/tests/test_skillgen_input_path_injection.py @@ -23,6 +23,8 @@ import pytest +from tests.shell_portability import resolve_shell_path, select_shell_executable + REPO_ROOT = Path(__file__).resolve().parent.parent SKILL_MD = REPO_ROOT / "graphify" / "skill.md" @@ -56,16 +58,8 @@ def step1_script() -> str: def _run_step1(script: str, input_path_value: str, cwd: Path) -> subprocess.CompletedProcess: - if sys.platform == "win32": - if len(input_path_value) > 2 and input_path_value[1] == ":": - drive = input_path_value[0].lower() - rest = input_path_value[2:].replace("\\", "/") - input_path_value = f"/mnt/{drive}{rest}" - else: - def _conv(m: re.Match) -> str: - clean_path = m.group(2).replace("\\", "/") - return f"/mnt/{m.group(1).lower()}/{clean_path}" - input_path_value = re.sub(r"([A-Za-z]):[\\/]([^\s\"'`;#|)\n]+)", _conv, input_path_value) + # Admit the shell explicitly; the heredoc preserves Python stdin payload bytes. + # Preserve the fixture's native spelling; Windows alone does not imply WSL. substituted = script.replace("INPUT_PATH", input_path_value).replace("\r\n", "\n") if sys.platform == "win32": # On Windows, writing to a temp script file avoids CreateProcess quote-escaping @@ -73,12 +67,12 @@ def _conv(m: re.Match) -> str: script_file = cwd / "_run_step1.sh" script_file.write_text(substituted, encoding="utf-8", newline="\n") return subprocess.run( - ["bash", "_run_step1.sh"], + [select_shell_executable("bash"), "_run_step1.sh"], cwd=cwd, capture_output=True, text=True, env={**os.environ, "PATH": os.environ.get("PATH", "")}, ) return subprocess.run( - ["bash", "-c", substituted], + [select_shell_executable("bash"), "-c", substituted], cwd=cwd, capture_output=True, text=True, env={**os.environ, "PATH": os.environ.get("PATH", "")}, ) @@ -133,14 +127,21 @@ def test_step1_does_not_execute_hostile_input_path( """A hostile INPUT_PATH containing shell metacharacters must never execute.""" script = _extract_step1_bash_block(skill_file) sentinel = tmp_path / f"PWNED_{attack_name}_{skill_file.replace('.', '_')}" - malicious = payload_fn(sentinel) + # Relative sentinels are reachable by the actual shell on every host. A + # Windows backslash path can otherwise make a vulnerable control look safe. + malicious = payload_fn(sentinel.name) - _run_step1(script, malicious, cwd=tmp_path) + result = _run_step1(script, malicious, cwd=tmp_path) assert not sentinel.exists(), ( f"hostile {attack_name} inside substituted INPUT_PATH for {skill_file} " f"must never execute as shell code" ) + assert result.returncode != 0, "a hostile nonexistent path must be rejected" + assert (tmp_path / "graphify-out" / ".graphify_python").is_file() + assert not (tmp_path / "graphify-out" / ".graphify_root").exists() + _run_step1("true INPUT_PATH\n", malicious, cwd=tmp_path) + assert sentinel.is_file(), "unsafe control must reach the same sentinel" # --- Backwards-compatible legacy test entry points --------------------------- @@ -150,28 +151,38 @@ def test_step1_does_not_execute_a_command_substitution_in_input_path(tmp_path: P """A malicious path containing $(...) must never run as shell code.""" script = _extract_step1_bash_block() sentinel = tmp_path / "PWNED" - malicious = f"$(touch {sentinel})" + malicious = f"$(touch {sentinel.name})" - _run_step1(script, malicious, cwd=tmp_path) + result = _run_step1(script, malicious, cwd=tmp_path) assert not sentinel.exists(), ( "a $(...) command substitution inside the substituted INPUT_PATH " "must never execute" ) + assert result.returncode != 0, "a hostile nonexistent path must be rejected" + assert (tmp_path / "graphify-out" / ".graphify_python").is_file() + assert not (tmp_path / "graphify-out" / ".graphify_root").exists() + _run_step1("true INPUT_PATH\n", malicious, cwd=tmp_path) + assert sentinel.is_file(), "unsafe control must reach the same sentinel" def test_step1_does_not_execute_a_semicolon_separated_command_in_input_path(tmp_path: Path): """A malicious path using `;` to chain a second command must never run.""" script = _extract_step1_bash_block() sentinel = tmp_path / "PWNED2" - malicious = f"nonexistent; touch {sentinel} #" + malicious = f"nonexistent; touch {sentinel.name} #" - _run_step1(script, malicious, cwd=tmp_path) + result = _run_step1(script, malicious, cwd=tmp_path) assert not sentinel.exists(), ( "a semicolon-separated command inside the substituted INPUT_PATH " "must never execute" ) + assert result.returncode != 0, "a hostile nonexistent path must be rejected" + assert (tmp_path / "graphify-out" / ".graphify_python").is_file() + assert not (tmp_path / "graphify-out" / ".graphify_root").exists() + _run_step1("true INPUT_PATH\n", malicious, cwd=tmp_path) + assert sentinel.is_file(), "unsafe control must reach the same sentinel" # --- Legitimate path handling & non-existent path failure --------------------- @@ -186,22 +197,14 @@ def test_step1_still_resolves_a_legitimate_path(tmp_path: Path, skill_file: str) result = _run_step1(script, str(project), cwd=tmp_path) + assert result.returncode == 0, result.stderr marker = tmp_path / "graphify-out" / ".graphify_root" assert marker.exists(), ( f"a legitimate path must still be resolved and written for {skill_file}; " f"stdout={result.stdout!r} stderr={result.stderr!r}" ) marker_content = marker.read_text(encoding="utf-8").strip() - if sys.platform == "win32" and project.drive: - drive_letter = project.drive[0].lower() - if marker_content.startswith(f"/mnt/{drive_letter}/"): - resolved_marker = Path(f"{drive_letter.upper()}:{marker_content[6:]}").resolve() - elif marker_content.startswith(f"/{drive_letter}/"): - resolved_marker = Path(f"{drive_letter.upper()}:{marker_content[2:]}").resolve() - else: - resolved_marker = Path(marker_content).resolve() - else: - resolved_marker = Path(marker_content).resolve() + resolved_marker = resolve_shell_path(marker_content, shell="bash") assert resolved_marker == project.resolve() assert marker_content.endswith("my project with spaces") diff --git a/tests/test_source_alias_provenance.py b/tests/test_source_alias_provenance.py new file mode 100644 index 0000000000..bca51a8ddf --- /dev/null +++ b/tests/test_source_alias_provenance.py @@ -0,0 +1,290 @@ +"""REQ-QML-020: discovered source aliases remain distinct portable identities.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess + +import pytest + +from graphify.build import build_from_json +from graphify.cache import file_hash +import graphify.extract as extraction +from graphify.export import to_json +from graphify.paths import load_node_link_graph +from graphify.watch import _rebuild_code +from tests.test_definition_file_portability import FOO_CPP, FOO_H +from tests.test_qt_final_incremental_parity import normalized, project, run +from tests.test_qt_product_publication import no_scratch, snapshot + + +PYTHON = b"def helper():\n return 7\n\ndef caller():\n return helper()\n" + + +@pytest.fixture +def directory_alias(request): + """Own real directory links; Windows junctions need no elevated token.""" + if os.name != "nt": + request.getfixturevalue("requires_symlinks") + aliases = [] + + def create(link, target): + if os.name == "nt": + result = subprocess.run(["cmd", "/d", "/c", "mklink", "/J", str(link), str(target)], + capture_output=True, text=True, check=False) + assert result.returncode == 0, "Temporary NTFS directory junction creation failed" + else: + link.symlink_to(target, target_is_directory=True) + aliases.append(link) + assert link.resolve() == target.resolve() + return link + + yield create + # Remove only owned link objects. Their target fixture tree stays intact. + for link in reversed(aliases): + if os.name == "nt": + link.rmdir() + else: + link.unlink() + + +def extracted(paths, root, cache): + result = extraction.extract(paths, root=root, cache_root=cache, parallel=False) + assert not result["failed_sources"] and not result["qml_failures"] + return result, build_from_json(result, root=root) + + +@pytest.mark.parametrize("aliased_root", [False, True]) +def test_req_qml020_ac01_discovered_directory_sources_survive_cold_warm_and_reload( + tmp_path, monkeypatch, directory_alias, aliased_root): + """A followed directory and its target keep separate nodes, calls and cache keys.""" + root = tmp_path / "repo" + (root / "real").mkdir(parents=True) + (root / "real/member.py").write_bytes(PYTHON) + directory_alias(root / "linked", root / "real") + scan = directory_alias(tmp_path / "scan", root) if aliased_root else root + paths = extraction.collect_files(scan, follow_symlinks=True, root=scan) + assert {path.relative_to(scan).as_posix() for path in paths} == { + "real/member.py", "linked/member.py"} + assert file_hash(scan / "real/member.py", root) != file_hash(scan / "linked/member.py", root) + cold, cold_graph = extracted(paths, scan, tmp_path / "cache") + identities = {source: {node["id"] for node in cold["nodes"] if node.get("source_file") == source} + for source in ("real/member.py", "linked/member.py")} + assert all(identities.values()) and identities["real/member.py"].isdisjoint(identities["linked/member.py"]) + for source, owned in identities.items(): + prefix = source.removesuffix(".py").replace("/", "_") + assert owned == {prefix, f"{prefix}_helper", f"{prefix}_caller"} + calls = [edge for edge in cold["edges"] if edge.get("source_file") == source + and edge.get("context") == "call"] + assert calls and all(edge["source"] in owned and edge["target"] in owned for edge in calls) + + def uncached(*_args): + pytest.fail("An unchanged source must use its own cached fragment") + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", uncached) + warm, warm_graph = extracted(paths, scan, tmp_path / "cache") + assert normalized(warm_graph) == normalized(cold_graph) + for directed in (False, True): + graph = build_from_json(warm, root=scan, directed=directed) + if directed: + assert all(graph.has_edge(f"{name}_member_caller", f"{name}_member_helper") + for name in ("real", "linked")) + output = tmp_path / f"round-trip-{directed}.json" + assert to_json(graph, {}, str(output), force=True) + reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert normalized(reloaded) == normalized(graph) + + +def test_req_qml020_ac01_leaf_alias_retains_its_source_below_an_aliased_root( + tmp_path, requires_symlinks, directory_alias, monkeypatch): + """A symlink file remains distinct when the entire scan root is also an alias.""" + root = tmp_path / "repo" + (root / "sub").mkdir(parents=True) + target = root / "real.py" + target.write_bytes(PYTHON) + (root / "sub/link.py").symlink_to(target) + scan = directory_alias(tmp_path / "scan", root) + paths = extraction.collect_files(scan) + cold, before = extracted(paths, scan, tmp_path / "cache") + assert {node.get("source_file") for node in cold["nodes"]} == {"real.py", "sub/link.py"} + + def uncached(*_args): + pytest.fail("Leaf alias and target fragments must both survive warm replay") + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", uncached) + _, after = extracted(paths, scan, tmp_path / "cache") + assert normalized(after) == normalized(before) + + +@pytest.mark.parametrize("fault", [OSError, RuntimeError]) +def test_req_qml020_ac02_failed_parent_anchor_keeps_only_accepted_physical_identity( + tmp_path, directory_alias, monkeypatch, capsys, fault): + """An inaccessible alias anchor falls back to a verified in-root source safely.""" + root = tmp_path / "repo" + root.mkdir() + (root / "member.py").write_bytes(PYTHON) + scan = directory_alias(tmp_path / "scan", root) + _, expected = extracted([root / "member.py"], root, tmp_path / "canonical-cache") + realpath, observed = extraction._cached_realpath, [] + body = "private backend body must not become source context" + + def inaccessible_parent(path, cwd): + if Path(path) == scan: + observed.append(path) + raise fault(body) + return realpath(path, cwd) + + # Forward the real cache owner's invalidation; the adapter only injects + # one ancestor lookup failure after accepted source facts are produced. + setattr(inaccessible_parent, "cache_clear", realpath.cache_clear) + monkeypatch.setattr(extraction, "_cached_realpath", inaccessible_parent) + for _ in range(2): + result, actual = extracted([scan / "member.py"], root, tmp_path / "alias-cache") + assert normalized(actual) == normalized(expected) + assert {node["source_file"] for node in result["nodes"]} == {"member.py"} + assert body not in json.dumps(result) + assert observed and body not in capsys.readouterr().out + + +def test_req_qml020_ac01_native_definition_keeps_discovered_directory_provenance( + tmp_path, directory_alias): + """Actual C++ declaration/definition merging keeps the walked source fields.""" + root = tmp_path / "repo" + (root / "real").mkdir(parents=True) + (root / "real/Foo.h").write_text(FOO_H, encoding="utf-8") + (root / "real/Foo.cpp").write_text(FOO_CPP, encoding="utf-8") + directory_alias(root / "linked", root / "real") + paths = [root / "linked/Foo.h", root / "linked/Foo.cpp"] + graphs = [] + for _ in range(2): + result, graph = extracted(paths, root, tmp_path / "cache") + definitions = [node for node in result["nodes"] if node.get("definition_file")] + assert definitions and all(node["source_file"] == "linked/Foo.h" + and node["definition_file"] == "linked/Foo.cpp" for node in definitions) + assert all(item["source_file"].startswith("linked/") + for item in result["nodes"] + result["edges"] if item.get("source_file")) + graphs.append(graph) + assert normalized(graphs[0]) == normalized(graphs[1]) + + +def test_req_qml020_ac02_foreign_alias_cannot_acquire_native_root_authority( + tmp_path, directory_alias): + """A lexically local link to a foreign QObject remains excluded and diagnostic.""" + root, foreign = tmp_path / "repo", tmp_path / "foreign" + root.mkdir() + foreign.mkdir() + target = foreign / "Foreign.h" + target.write_bytes(b"class Foreign : public QObject { Q_OBJECT public: Q_INVOKABLE void run(); };\n") + directory_alias(root / "linked", foreign) + assert extraction.collect_files(root, follow_symlinks=True, root=root) == [] + before = target.read_bytes() + result = extraction.extract([root / "linked/Foreign.h"], root=root, + cache_root=tmp_path / "cache", parallel=False) + assert result["failed_sources"] == [str(root / "linked/Foreign.h")] + assert result["qml_failures"] == [{"code": "QT_CPP_ROOT", "source_file": ""}] + assert not [node for node in result["nodes"] if node.get("metadata", {}).get("qt")] + assert target.read_bytes() == before + + +def test_req_qml020_ac03_watch_rename_deletion_and_repeat_preserve_discovered_source( + tmp_path, directory_alias): + """Real graph publication tracks two renames, deletion and no-change replay.""" + root = tmp_path / "repo" + (root / "real").mkdir(parents=True) + (root / ".graphifyignore").write_text("real/\n", encoding="utf-8") + current = root / "real/old.py" + current.write_bytes(PYTHON) + directory_alias(root / "linked", root / "real") + output = root / "graphify-out/graph.json" + assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False) + for name in ("first.py", "second.py"): + destination = current.with_name(name) + current.rename(destination) + current = destination + assert _rebuild_code(root, changed_paths=[Path("linked") / name], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + actual = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert {data.get("source_file") for _, data in actual.nodes(data=True)} == {f"linked/{name}"} + paths = extraction.collect_files(root, follow_symlinks=True, root=root) + _, full = extracted(paths, root, tmp_path / f"full-{name}") + assert normalized(actual) == normalized(full) + accepted = output.read_bytes() + assert _rebuild_code(root, changed_paths=[], follow_symlinks=True, no_cluster=True, acquire_lock=False) + assert output.read_bytes() == accepted + current.unlink() + assert _rebuild_code(root, changed_paths=[Path("linked/second.py")], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + retired = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert retired.number_of_nodes() == retired.number_of_edges() == 0 + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml020_ac03_policy19_products_refresh_retain_and_recover( + tmp_path, directory_alias, monkeypatch, operation): + """An unchanged Qt cohort refreshes aliases, with real failed publication retention.""" + import graphify.qt_analysis_state as state + import graphify.qt_incremental as policy + + root = tmp_path / "repo" + root.mkdir() + project(root) + (root / "real").mkdir() + (root / "real/member.py").write_bytes(PYTHON) + directory_alias(root / "linked", root / "real") + (root / ".graphifyignore").write_text("real/\n", encoding="utf-8") + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + with monkeypatch.context() as prior: + prior.setattr(policy, "QT_POLICY_VERSION", 19) + run(root, prior, operation, follow_symlinks=True) + output = root / "graphify-out" + before, old_stamp = snapshot(output), state.read_qt_fingerprint(output) + observed, real_extract = [], extraction.extract + + def record_inputs(paths, *args, **kwargs): + paths = list(paths) + observed.append({path.relative_to(root).as_posix() for path in paths}) + return real_extract(paths, *args, **kwargs) + + monkeypatch.setattr(extraction, "extract", record_inputs) + real_commit = state.commit_qt_analysis + + def fail_after_staging(*args, **kwargs): + real_commit(*args, **kwargs) + raise OSError("public source identity refresh publication failure") + + with monkeypatch.context() as failed: + failed.setattr(state, "commit_qt_analysis", fail_after_staging) + if operation == "manual": + with pytest.raises(SystemExit) as rejection: + run(root, failed, operation, [], follow_symlinks=True) + assert rejection.value.code == 1 + else: + assert not _rebuild_code(root, changed_paths=[], no_cluster=True, follow_symlinks=True) + assert snapshot(output) == before and state.read_qt_fingerprint(output) == old_stamp + no_scratch(output) + repaired = run(root, monkeypatch, operation, [], follow_symlinks=True) + assert state.read_qt_fingerprint(output) != old_stamp + assert observed and {"Main.qml", "backend.h", "loader.cpp"} <= set.union(*observed) + assert "linked/member.py" in {data.get("source_file") for _, data in repaired.nodes(data=True)} + paths = extraction.collect_files(root, root=root, follow_symlinks=True) + _, full = extracted(paths, root, tmp_path / "full") + assert normalized(repaired) == normalized(full) + accepted = snapshot(output) + assert normalized(run(root, monkeypatch, operation, [], follow_symlinks=True)) == normalized(repaired) + assert snapshot(output) == accepted + no_scratch(output) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX directory-symlink discovery profile") +def test_req_qml020_ac03_directory_symlink_requires_explicit_discovery_profile(tmp_path, directory_alias): + """A real alias remains absent by default; only supported opt-in admits it.""" + root = tmp_path.resolve() + real = root / "real" + real.mkdir() + (real / "member.py").write_bytes(PYTHON) + directory_alias(root / "linked", real) + (root / ".graphifyignore").write_text("real/\n", encoding="utf-8") + assert extraction.collect_files(root, root=root) == [] + assert extraction.collect_files(root, root=root, follow_symlinks=True) == [root / "linked/member.py"] diff --git a/tests/test_source_identity_remap_owners.py b/tests/test_source_identity_remap_owners.py new file mode 100644 index 0000000000..4a0932a568 --- /dev/null +++ b/tests/test_source_identity_remap_owners.py @@ -0,0 +1,94 @@ +"""INC-QML-44/45: shared resolved IDs cannot replace walked source owners.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +import graphify.extract as extraction +from graphify.export import to_json +from graphify.paths import load_node_link_graph +from tests.test_qt_final_incremental_parity import normalized +from tests.test_source_alias_provenance import directory_alias # noqa: F401 + + +SOURCE = b"def helper():\n return 7\n\ndef caller():\n return helper()\n" + + +def fixture(root, directory_alias): + """Only actual discovered aliases share a physical source; no source is copied.""" + real = root / "real" + real.mkdir() + source = real / "member.py" + source.write_bytes(SOURCE) + first = directory_alias(root / "first", real) / source.name + second = directory_alias(root / "second", real) / source.name + return source, first, second + + +def graph(paths, root, cache): + result = extraction.extract(paths, root=root, cache_root=cache, parallel=False) + assert not result["failed_sources"] and not result["qml_failures"] + return build_from_json(result, root=root, directed=True), result + + +def assert_owners(result, names): + """File nodes and actual calls retain each owner's complete disjoint identity set.""" + for name in names: + owned = {node["id"] for node in result["nodes"] if node.get("source_file") == name} + prefix = name.removesuffix(".py").replace("/", "_") + assert owned == {prefix, prefix + "_helper", prefix + "_caller"} + calls = [edge for edge in result["edges"] if edge.get("source_file") == name + and edge.get("context") == "call"] + assert calls and all(edge["source"] in owned and edge["target"] in owned for edge in calls) + + +@pytest.mark.parametrize("canonical_first", [True, False]) +@pytest.mark.parametrize("relative", [True, False]) +def test_req_qml020_ac02_primary_physical_owner_survives_both_batch_orders( + tmp_path, directory_alias, monkeypatch, canonical_first, relative): + """An appended alias cannot steal a supplied physical owner, even with relative inputs.""" + root = tmp_path.resolve() + source, first, second = fixture(root, directory_alias) + expected, _ = graph([source, first, second], root, root / "canonical-cache") + paths = [source, first, second] if canonical_first else [first, second, source] + monkeypatch.chdir(root) + if relative: + paths = [path.relative_to(root) for path in paths] + actual, result = graph(paths, root, root / "ordered-cache") + assert_owners(result, ["real/member.py", "first/member.py", "second/member.py"]) + assert normalized(actual) == normalized(expected) + warm, _ = graph(paths, root, root / "ordered-cache") + assert normalized(warm) == normalized(expected) + output = root / "reloaded.json" + assert to_json(warm, {}, str(output), force=True) + assert normalized(load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))) == normalized(expected) + assert source.read_bytes() == SOURCE + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_req_qml020_ac02_multiple_aliases_cannot_authorize_arbitrary_physical_target( + tmp_path, directory_alias, reverse): + """A shared import keeps its physical endpoint without inventing an accepted owner.""" + root = tmp_path.resolve() + source, first, second = fixture(root, directory_alias) + consumer = root / "consumer.py" + consumer.write_bytes(b"import real.member\n\ndef use():\n return real.member.helper()\n") + paths = [consumer, second, first] if reverse else [consumer, first, second] + actual, result = graph(paths, root, root / "cache") + assert_owners(result, ["first/member.py", "second/member.py"]) + assert not {"real_member", "real_member_helper", "real_member_caller"} & { + node["id"] for node in result["nodes"]} + # Graph assembly retains its existing placeholder for a dangling import; + # that placeholder carries no accepted source/declaration authority. + assert actual.nodes["real_member"].get("source_file") != "real/member.py" + imports = [edge for edge in result["edges"] if edge.get("relation") == "imports" + and edge.get("source_file") == "consumer.py"] + assert imports and all(edge["target"] == "real_member" for edge in imports) + assert all(edge["target"] not in {"first_member", "second_member"} for edge in imports) + assert not [edge for edge in result["edges"] if edge.get("source_file") == "consumer.py" + and edge.get("context") == "call"] + warm, _ = graph(paths, root, root / "cache") + assert normalized(warm) == normalized(actual) + assert source.read_bytes() == SOURCE diff --git a/tests/test_source_input_admission.py b/tests/test_source_input_admission.py new file mode 100644 index 0000000000..903540e504 --- /dev/null +++ b/tests/test_source_input_admission.py @@ -0,0 +1,191 @@ +"""INC-QML-45: native filename admission refuses unsafe partial extraction.""" +from __future__ import annotations + +import ctypes +import json +import os +from pathlib import Path + +import pytest + +import graphify.extract as extraction +import graphify.source_identity as identity +import graphify.watch as watch +from tests.test_qt_final_incremental_parity import clean, normalized, project, run +from tests.test_qt_product_publication import no_scratch, snapshot + + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="Windows native filename admission") + + +def caches(output): + """Retain exact pre-admission cache bytes alongside the accepted product cohort.""" + return {path.relative_to(output).as_posix(): path.read_bytes() + for path in (output / "cache").rglob("*") if path.is_file()} + + +def reject_native_api(monkeypatch, source, alternative, fault): + """Isolate only GetLongPathNameW; filesystem checks and callers remain real.""" + original = ctypes.windll.kernel32.GetLongPathNameW + observed = [] + + def reject(path, buffer, capacity): + if Path(path).name != source.name: + return original(path, buffer, capacity) + observed.append(path) + if fault == "backend": + raise PermissionError("synthetic-private-native-body\ncredentials=redact") + if fault == "zero": + return 0 + if fault == "oversize": + return capacity + if fault == "empty": + return 1 + if fault == "length": + buffer.value = str(source) + return 1 + buffer.value = str(alternative) + return len(buffer.value.encode("utf-16-le")) // 2 + + monkeypatch.setattr(ctypes.windll.kernel32, "GetLongPathNameW", reject) + return observed + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +@pytest.mark.parametrize("fault", ["zero", "oversize", "empty", "length", "target", "backend"]) +def test_req_qml012_ac01_ac02_qml020_ac01_native_failure_retains_repairs_and_repeats( + tmp_path, monkeypatch, capsys, operation, fault): + """Admission failure precedes workers/cache writes; actual updates recover and repeat.""" + root = tmp_path.resolve() + source = project(root) + alternative = root / "keep.py" + monkeypatch.chdir(root) + initial = run(root, monkeypatch, operation) + output = root / "graphify-out" + before, cached = snapshot(output), caches(output) + assert cached + source.write_bytes(source.read_bytes().replace(b"adjustment", b"offset")) + capsys.readouterr() + with monkeypatch.context() as failed: + observed = reject_native_api(failed, source, alternative, fault) + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(root, monkeypatch, operation, [source]) + assert rejected.value.code == 1 + else: + assert not watch._rebuild_code(root, changed_paths=[source], no_cluster=True) + assert observed and snapshot(output) == before and caches(output) == cached + captured = capsys.readouterr() + text = captured.out + captured.err + assert "SOURCE_INPUT_IDENTITY_FAILED" in text + assert f"source={json.dumps(source.relative_to(root).as_posix())};" in text + assert "synthetic-private-native-body" not in text and "credentials=redact" not in text + assert str(root) not in text.split("Rebuild failed:")[-1] + no_scratch(output) + repaired = run(root, monkeypatch, operation, [source]) + assert normalized(repaired) != normalized(initial) + assert normalized(repaired) == normalized(clean(root, root / "clean-cache")) + accepted = snapshot(output) + run(root, monkeypatch, operation, []) + assert snapshot(output) == accepted + no_scratch(output) + + +@pytest.mark.parametrize("shape", ["missing", "directory", "child_of_file"]) +def test_req_qml020_ac01_missing_and_nonfile_keep_existing_admission_shape(tmp_path, monkeypatch, shape): + """Legacy missing/non-file inputs bypass filename expansion and keep their path.""" + source = tmp_path / "existing.py" + source.write_bytes(b"def existing():\n return 1\n") + path = {"missing": tmp_path / "missing.py", "directory": tmp_path, + "child_of_file": source / "child.py"}[shape] + + def forbidden(*args): + raise AssertionError("native expansion must not run") + + monkeypatch.setattr(ctypes.windll.kernel32, "GetLongPathNameW", forbidden) + assert identity.normalize_input_source(path, tmp_path) == path + + +@pytest.mark.parametrize("label", ["a;delimiter.py", "a\u2028.py", "x" * 157 + ".py", "x" * 158 + ".py"]) +def test_req_qml012_ac04_native_context_is_quoted_bounded_and_lexical(tmp_path, monkeypatch, label): + """Diagnostic context rejects control/oversized text without exposing backend bodies.""" + source = tmp_path / label + source.write_bytes(b"pass\n") + reject_native_api(monkeypatch, source, source, "backend") + with pytest.raises(ValueError) as rejected: + identity.normalize_input_source(source, tmp_path) + context = label if len(label) <= 160 and "\u2028" not in label else "" + assert str(rejected.value) == (f'SOURCE_INPUT_IDENTITY_FAILED: source={json.dumps(context)}; native source ' + 'spelling unavailable; repair filesystem access and retry') + assert identity._source_context(source, None) == "" + + +@pytest.mark.parametrize("fault", [OSError, RuntimeError]) +def test_req_qml012_ac04_failed_context_lookup_keeps_bounded_admission_error(tmp_path, monkeypatch, fault): + """A failed CWD/lexical adapter cannot replace the actual native rejection diagnostic.""" + source = tmp_path / "source.py" + source.write_bytes(b"pass\n") + reject_native_api(monkeypatch, source, source, "backend") + original = os.path.abspath + + def unavailable(path): + if Path(path) == source: + raise fault("synthetic-private-cwd-body") + return original(path) + + monkeypatch.setattr(identity.os.path, "abspath", unavailable) + with pytest.raises(ValueError) as rejected: + identity.normalize_input_source(source, tmp_path) + assert str(rejected.value) == ('SOURCE_INPUT_IDENTITY_FAILED: source=""; native source ' + 'spelling unavailable; repair filesystem access and retry') + + +def test_req_qml012_ac01_native_admission_refuses_before_any_worker_or_cache_write(tmp_path, monkeypatch): + """Direct extraction reports refusal without claiming writer retention or doing work.""" + source = tmp_path / "source.py" + source.write_bytes(b"def original():\n return 1\n") + cache = tmp_path / "cache-owner" + extraction.extract([source], root=tmp_path, cache_root=cache, parallel=False) + before = {path.relative_to(cache): path.read_bytes() for path in cache.rglob("*") if path.is_file()} + assert before + source.write_bytes(b"def changed():\n return 2\n") + reject_native_api(monkeypatch, source, source, "zero") + + def forbidden(*args): + raise AssertionError("worker must not run") + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", forbidden) + with pytest.raises(ValueError, match="SOURCE_INPUT_IDENTITY_FAILED") as rejected: + extraction.extract([source], root=tmp_path, cache_root=cache, parallel=False) + assert "retained" not in str(rejected.value) + assert before == {path.relative_to(cache): path.read_bytes() for path in cache.rglob("*") if path.is_file()} + + +@pytest.mark.parametrize("operation,fault", [("stat", OSError), ("stat", RuntimeError), + ("samefile", None)]) +def test_req_qml012_ac01_native_filesystem_identity_failure_is_bounded(tmp_path, monkeypatch, operation, fault): + """A stat failure or changed entry proof cannot silently admit an existing file.""" + source = tmp_path / "source.py" + source.write_bytes(b"pass\n") + original = getattr(Path, operation) + + def unavailable(path, *args, **kwargs): + if path == source: + if fault is not None: + raise fault("synthetic-private-filesystem-body") + return False + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, operation, unavailable) + with pytest.raises(ValueError) as rejected: + identity.normalize_input_source(source, tmp_path) + assert str(rejected.value) == ('SOURCE_INPUT_IDENTITY_FAILED: source="source.py"; native source ' + 'spelling unavailable; repair filesystem access and retry') + + +def test_req_qml020_ac01_nonwindows_input_needs_no_native_lookup(tmp_path, monkeypatch): + """The platform guard retains POSIX source handling without a Windows dependency.""" + source = tmp_path / "source.py" + with monkeypatch.context() as other_platform: + other_platform.setattr(identity.os, "name", "posix") + assert identity.normalize_input_source(source, tmp_path) is source diff --git a/tests/test_terraform_modules.py b/tests/test_terraform_modules.py index 8e996fbb1e..a46546f5cb 100644 --- a/tests/test_terraform_modules.py +++ b/tests/test_terraform_modules.py @@ -66,19 +66,39 @@ def test_environment_application_base_topology_and_source_provenance(tmp_path): assert len(_module_edges(result)) == 2 -def test_same_named_directories_and_cross_file_references_stay_separate(tmp_path): +@pytest.mark.parametrize("line_ending", ["\n", "\r\n"]) +def test_same_named_directories_and_cross_file_references_stay_separate(tmp_path, line_ending): + """Portable provenance and scoped references survive original UTF-8 bytes and JSON.""" for directory in ("dev/app", "prod/app", "a-b/app", "a_b/app"): - _write(tmp_path, f"{directory}/main.tf", 'variable "name" {}\n') - _write(tmp_path, f"{directory}/use.tf", 'output "name" { value = var.name }\n') + for file, body in (("main.tf", 'variable "name" {}'), + ("use.tf", 'output "name" { value = var.name }')): + # Write exact bytes rather than letting the host translate CRLF. + source = f"# café{line_ending}{body}{line_ending}".encode("utf-8") + _write(tmp_path, f"{directory}/{file}", "").write_bytes(source) result = _extract(tmp_path) _assert_integrity(result) variables = [n for n in result["nodes"] if n["label"] == "var.name"] assert len({n["id"] for n in variables}) == 4 + references = [e for e in result["edges"] if e["relation"] == "references"] + assert len(references) == 4 + # Facade source paths are POSIX on every host. Native str(Path(...)) uses + # backslashes on Windows and would reject correct portable graph facts. + from graphify.export import to_json + from graphify.paths import load_node_link_graph + graph_path = tmp_path / "roundtrip.json" + to_json(build_from_json(result, root=tmp_path, directed=True), {}, str(graph_path), force=True) + restored = load_node_link_graph(json.loads(graph_path.read_text(encoding="utf-8"))) + assert restored.is_directed() for variable in variables: - source = str(Path(variable["source_file"]).parent / "use.tf") + source = (Path(variable["source_file"]).parent / "use.tf").as_posix() output = _node(result, "output.name", source) - assert any(e["source"] == output["id"] and e["target"] == variable["id"] - for e in result["edges"]) + edge = next(e for e in references if e["source"] == output["id"]) + assert edge["target"] == variable["id"] + assert edge["source_file"] == source + assert edge["source_location"] == output["source_location"] == "L2" + assert edge["confidence"] == "EXTRACTED" + assert restored.has_edge(output["id"], variable["id"]) + assert restored.edges[output["id"], variable["id"]]["source_file"] == source def test_raw_extractor_scopes_ids_by_full_directory(tmp_path): diff --git a/tests/test_uninstall_scope.py b/tests/test_uninstall_scope.py index 9102cdfabd..ca169905ce 100644 --- a/tests/test_uninstall_scope.py +++ b/tests/test_uninstall_scope.py @@ -13,6 +13,7 @@ from __future__ import annotations from pathlib import Path +import platform as host_platform import pytest @@ -30,6 +31,19 @@ ] +@pytest.fixture(autouse=True, params=["Linux", "Windows"]) +def install_host_profile(monkeypatch, request): + """Exercise both destination contracts independently of the physical host.""" + monkeypatch.setattr(host_platform, "system", lambda: request.param) + + +def _plant_user_skill_tree(platform: str, dot_dir: str) -> Path: + """Plant Gemini's Windows global copy in .agents; project copies stay .gemini.""" + if platform == "gemini" and host_platform.system() == "Windows": + dot_dir = ".agents" + return _plant_skill_tree(Path.home(), dot_dir) + + def _plant_skill_tree(root: Path, dot_dir: str) -> Path: """Create //skills/graphify/{SKILL.md, references/x.md, .graphify_version}.""" skill_dir = root / dot_dir / "skills" / "graphify" @@ -43,7 +57,7 @@ def _plant_skill_tree(root: Path, dot_dir: str) -> Path: @pytest.mark.parametrize("uninstall_fn,platform,dot_dir", PLATFORMS) def test_project_dir_call_never_touches_global(uninstall_fn, platform, dot_dir, tmp_path): """fn(project_dir) removes only the project skill tree (#2215 trap closed).""" - global_tree = _plant_skill_tree(Path.home(), dot_dir) + global_tree = _plant_user_skill_tree(platform, dot_dir) proj_dir = tmp_path / "proj" project_tree = _plant_skill_tree(proj_dir, dot_dir) @@ -59,7 +73,7 @@ def test_project_dir_call_never_touches_global(uninstall_fn, platform, dot_dir, @pytest.mark.parametrize("uninstall_fn,platform,dot_dir", PLATFORMS) def test_bare_call_still_removes_global(uninstall_fn, platform, dot_dir, tmp_path, monkeypatch): """fn() with no args keeps the historical CLI behavior: global skill removed.""" - global_tree = _plant_skill_tree(Path.home(), dot_dir) + global_tree = _plant_user_skill_tree(platform, dot_dir) cwd = tmp_path / "empty-cwd" cwd.mkdir() monkeypatch.chdir(cwd) @@ -73,7 +87,7 @@ def test_bare_call_still_removes_global(uninstall_fn, platform, dot_dir, tmp_pat @pytest.mark.parametrize("uninstall_fn,platform,dot_dir", PLATFORMS) def test_remove_user_skill_opt_in_with_project_dir(uninstall_fn, platform, dot_dir, tmp_path): """fn(pd, remove_user_skill=True) removes the global skill, leaves the project tree.""" - global_tree = _plant_skill_tree(Path.home(), dot_dir) + global_tree = _plant_user_skill_tree(platform, dot_dir) proj_dir = tmp_path / "proj" project_tree = _plant_skill_tree(proj_dir, dot_dir) @@ -88,7 +102,7 @@ def test_remove_user_skill_opt_in_with_project_dir(uninstall_fn, platform, dot_d @pytest.mark.parametrize("uninstall_fn,platform,dot_dir", PLATFORMS) def test_project_true_removes_only_project_tree(uninstall_fn, platform, dot_dir, tmp_path): """fn(pd, project=True) removes only the project skill tree.""" - global_tree = _plant_skill_tree(Path.home(), dot_dir) + global_tree = _plant_user_skill_tree(platform, dot_dir) proj_dir = tmp_path / "proj" project_tree = _plant_skill_tree(proj_dir, dot_dir) diff --git a/tests/test_upstream_qt_cache_integration.py b/tests/test_upstream_qt_cache_integration.py new file mode 100644 index 0000000000..c287e69bb4 --- /dev/null +++ b/tests/test_upstream_qt_cache_integration.py @@ -0,0 +1,194 @@ +"""INC-QML-49: combined Qt/Python cache producers preserve facts and ownership.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import graphify.cache as cache +import graphify.extract as extraction +import graphify.qt_analysis_state as qt_state +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from graphify.watch import _rebuild_code +from tests.test_qt_final_incremental_parity import normalized, project, published +from tests.test_qt_product_publication import no_scratch, snapshot + + +@pytest.fixture +def integrated_versions(monkeypatch): + """Isolate installed metadata; the combined release owns the actual schema.""" + real_version = qt_state.version + + def current_version(package): + return "0.9.76" if package == "graphifyy" else real_version(package) + + monkeypatch.setattr(cache, "_EXTRACTOR_VERSION", "0.9.76") + monkeypatch.setattr(cache, "_cleaned_ast_dirs", set()) + # Each case owns a fresh project. Keep process bookkeeping isolated, then + # complete its real stat-index flush before fixture restoration. + for name, value in (("_stat_index", {}), ("_stat_index_root", None), + ("_stat_index_anchor", None), ("_stat_index_path", None), + ("_stat_index_dirty", False)): + monkeypatch.setattr(cache, name, value, raising=False) + monkeypatch.setattr(qt_state, "version", current_version) + yield real_version + cache._flush_stat_index() + + +def semantic_entry(root): + """Seed a real prompt-owned entry whose bytes AST migration must not touch.""" + source = root / "notes.md" + source.write_text("Public cache integration note.\n", encoding="utf-8") + prompt = "Public semantic extraction contract" + cache.save_cached(source, { + "nodes": [{"id": "public-note", "label": "note", "source_file": str(source)}], + "edges": [], + }, root=root, kind="semantic", prompt=prompt) + directory = cache.cache_dir(root, "semantic", cache.prompt_fingerprint(prompt)) + entry = directory / f"{cache.file_hash(source, root)}.json" + return source, prompt, entry, entry.read_bytes() + + +def assert_semantic_preserved(root, state): + source, prompt, entry, before = state + loaded = cache.load_cached(source, root=root, kind="semantic", prompt=prompt) + assert loaded is not None and loaded["nodes"][0]["id"] == "public-note" + assert entry.read_bytes() == before + + +def python_source(root): + source = root / "caller.py" + source.write_text( + "import requests\n\ndef fetch():\n return requests.get('/data')\n\n" + "def shadowed(requests):\n return requests.get('/private')\n", + encoding="utf-8", + ) + return source + + +@pytest.mark.parametrize("old_schema", [5, 12]) +@pytest.mark.parametrize("old_version", ["0.9.74", "0.9.76"]) +def test_req_qml003_ac04_qml018_ac05_old_namespaces_miss_and_refresh( + tmp_path, monkeypatch, integrated_versions, old_schema, old_version): + """Real legacy writes miss even at the same version, refresh, then warm-hit.""" + source = python_source(tmp_path) + semantic = semantic_entry(tmp_path) + # Both historical producers used this real cache writer. Their raw calls + # cannot establish the integrated lexical/Qt fact contract from their epoch. + with monkeypatch.context() as prior: + prior.setattr(cache, "_EXTRACTOR_VERSION", old_version) + prior.setattr(cache, "_AST_CACHE_SCHEMA", old_schema) + cache.save_cached(source, { + "nodes": [{"id": "old", "label": "stale", "source_file": str(source)}], + "edges": [], "raw_calls": [{"caller_nid": "old", "callee": "get"}], + }, root=tmp_path) + old_directory = cache.cache_dir(tmp_path) + assert (old_directory / f"{cache.file_hash(source, tmp_path)}.json").exists() + assert cache.load_cached(source, root=tmp_path) is None + assert not old_directory.exists() + assert_semantic_preserved(tmp_path, semantic) + cold = extraction.extract([source], root=tmp_path, cache_root=tmp_path, parallel=False) + fresh = cache.load_cached(source, root=tmp_path) + assert fresh is not None + assert {item.get("_python_receiver_shadowed") for item in fresh["raw_calls"]} == {False, True} + assert cache.cache_dir(tmp_path).name == "v0.9.76-s13" + warm = extraction.extract([source], root=tmp_path, cache_root=tmp_path, parallel=False) + assert normalized(build_from_json(cold, directed=True, root=tmp_path)) == normalized( + build_from_json(warm, directed=True, root=tmp_path)) + assert not any(node.get("label") == "stale" for node in warm["nodes"]) + assert_semantic_preserved(tmp_path, semantic) + + +def test_req_qml003_ac04_qml018_ac05_mixed_cold_warm_serialized_facts( + tmp_path, monkeypatch, integrated_versions): + """Real Qt reparse and Python warm hits agree through directed JSON reload.""" + project(tmp_path) + source = python_source(tmp_path) + semantic = semantic_entry(tmp_path) + paths = extraction.collect_files(tmp_path, root=tmp_path) + cold = extraction.extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + observed, real_extract = [], extraction._safe_extract_with_xaml_root + + def observe(extractor, path, *args, **kwargs): + observed.append(Path(path).suffix) + return real_extract(extractor, path, *args, **kwargs) + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe) + warm = extraction.extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert observed and ".py" not in observed + assert not warm["failed_sources"] and not warm["qml_failures"] + assert any(qt_metadata(node).get("kind") == "property" for node in warm["nodes"]) + assert any(qml_metadata(node).get("kind") == "type_use" and + qml_metadata(node).get("status") == "resolved" for node in warm["nodes"]) + callers = {node["label"]: node["id"] for node in warm["nodes"] + if node.get("source_file") == "caller.py"} + assert any(edge["source"] == callers["fetch()"] and edge["target"] == "requests" + and edge["relation"] == "calls" for edge in warm["edges"]) + assert not any(edge["source"] == callers["shadowed()"] and edge["target"] == "requests" + and edge["relation"] == "calls" for edge in warm["edges"]) + raw = cache.load_cached(source, root=tmp_path) + assert raw is not None + assert {item.get("_python_receiver_shadowed") for item in raw["raw_calls"]} == {False, True} + graphs = [] + for name, result in (("cold", cold), ("warm", warm)): + graph = build_from_json(result, directed=True, root=tmp_path) + output = tmp_path / f"{name}.json" + assert to_json(graph, {}, str(output), force=True) + graphs.append(load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))) + assert normalized(graphs[0]) == normalized(graphs[1]) + assert_semantic_preserved(tmp_path, semantic) + + +def test_req_qml011_ac03_ac04_qml018_ac06_upgrade_failure_retains_repairs_repeats( + tmp_path, monkeypatch, integrated_versions): + """A real schema-13 cache replacement failure cannot advance prior products.""" + project(tmp_path) + source = python_source(tmp_path) + semantic = semantic_entry(tmp_path) + real_version = integrated_versions + with monkeypatch.context() as prior: + prior.setattr(cache, "_EXTRACTOR_VERSION", "0.9.74") + prior.setattr(cache, "_AST_CACHE_SCHEMA", 12) + prior.setattr(qt_state, "version", lambda name: + "0.9.74" if name == "graphifyy" else real_version(name)) + assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False) + output = tmp_path / "graphify-out" + accepted, initial = snapshot(output), normalized(published(tmp_path)) + before_sources = {path.name: path.read_bytes() for path in tmp_path.iterdir() if path.is_file()} + failed_targets, real_replace = [], cache._os_replace_with_fallback + + def fail_current_ast(temporary, target): + target = Path(target) + if target.parent.name == "v0.9.76-s13": + # Serialization and staging must really finish before the injected + # replacement failure reaches the actual cache persistence owner. + assert Path(temporary).is_file() + staged = json.loads(Path(temporary).read_text(encoding="utf-8")) + assert staged.get("raw_calls") + failed_targets.append(target) + raise OSError("public combined-cache replacement failure") + return real_replace(temporary, target) + + with monkeypatch.context() as failed: + failed.setattr(cache, "_os_replace_with_fallback", fail_current_ast) + assert not _rebuild_code(tmp_path, changed_paths=[], no_cluster=True, acquire_lock=False) + assert failed_targets + assert snapshot(output) == accepted + assert_semantic_preserved(tmp_path, semantic) + no_scratch(output) + assert _rebuild_code(tmp_path, changed_paths=[], no_cluster=True, acquire_lock=False) + assert normalized(published(tmp_path)) == initial + repaired = snapshot(output) + assert repaired != accepted + assert cache.load_cached(source, root=tmp_path) is not None + assert _rebuild_code(tmp_path, changed_paths=[], no_cluster=True, acquire_lock=False) + assert snapshot(output) == repaired + assert normalized(published(tmp_path)) == initial + assert_semantic_preserved(tmp_path, semantic) + assert before_sources == {path.name: path.read_bytes() for path in tmp_path.iterdir() if path.is_file()} + no_scratch(output) diff --git a/tests/test_upstream_qt_discovery_integration.py b/tests/test_upstream_qt_discovery_integration.py new file mode 100644 index 0000000000..0459343be7 --- /dev/null +++ b/tests/test_upstream_qt_discovery_integration.py @@ -0,0 +1,178 @@ +"""INC-QML-49: upstream corpus exclusions coexist with Qt metadata discovery.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +import graphify.detect as detection +import graphify.extract as extraction +from graphify.build import build_from_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.watch import _rebuild_code +from tests.test_qt_final_incremental_parity import ( + assert_native_fixture, normalized, project, run, +) +from tests.test_qt_product_publication import no_scratch, snapshot +from tests.test_source_alias_provenance import directory_alias # noqa: F401 +from tests.test_watch_physical_coowners import cache_bytes + + +INSTALLED = ".opencode/plugins/graphify.js" +ORDINARY = "src/plugins/graphify.js" +MANIFESTS = {"CMakeLists.txt", "qmltools/qmldir", "qmltools/tools.pro"} + + +def corpus(root): + """A real native bridge, independent qmake module and copied hook share one scan.""" + root.mkdir() + project(root) + files = { + INSTALLED: "// graphify generated hook\nfunction copiedHook() { return 7; }\n", + ORDINARY: "// graphify generated hook\nfunction copiedHook() { return 7; }\n", + "qmltools/qmldir": "module Public.Hidden\nHidden 1.0 Hidden.qml\n", + "qmltools/tools.pro": "QT += qml\nQML_IMPORT_NAME = Public.Hidden\n" + "QML_IMPORT_VERSION = 1.0\nQML_FILES += Hidden.qml\n", + "qmltools/Hidden.qml": "import QtQml\nQtObject { property int localValue: 7 }\n", + "NOT_METADATA": "Item { property int excluded: 1 }\n", + ".graphifyignore": "blocked/\n", + } + for name, text in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + # Malformed excluded metadata must never reach an extractor or resolver. + (root / "blocked").mkdir() + for name in ("CMakeLists.txt", "qmldir", "Hidden.qml"): + (root / "blocked" / name).write_bytes(b"\xff\x00") + + +def collected(root, follow): + return extraction.collect_files(root, root=root, follow_symlinks=follow) + + +def direct(root, cache, follow): + """Exercise the actual collector, facade, Qt resolver and graph assembly.""" + facts = extraction.extract(collected(root, follow), root=root, cache_root=cache, + parallel=False) + assert not facts["failed_sources"] and not facts["qml_failures"] + return build_from_json(facts, root=root) + + +def assert_membership(graph): + sources = {node.get("source_file") for _, node in graph.nodes(data=True)} + assert MANIFESTS <= sources and ORDINARY in sources and INSTALLED not in sources + assert "NOT_METADATA" not in sources + assert not any(source and source.startswith(("blocked/", "outside/")) for source in sources) + assert any(qml_metadata(node).get("kind") == "qt_module" + for _, node in graph.nodes(data=True) if node.get("source_file") == "CMakeLists.txt") + assert any(qml_metadata(node).get("kind") == "module" + for _, node in graph.nodes(data=True) if node.get("source_file") == "qmltools/qmldir") + assert_native_fixture(graph) + + +@pytest.mark.parametrize("follow", [False, True]) +def test_req_qml002_ac02_ac03_detect_and_collect_share_named_metadata_and_copy_exclusions( + tmp_path, follow): + """Both directory profiles keep exact-name manifests and reject only installed path shapes.""" + root = tmp_path / "repo" + corpus(root) + detected = {Path(path).relative_to(root).as_posix() + for paths in detection.detect(root, follow_symlinks=follow)["files"].values() + for path in paths} + actual = {path.relative_to(root).as_posix() for path in collected(root, follow)} + for sources in (detected, actual): + assert MANIFESTS <= sources and ORDINARY in sources and INSTALLED not in sources + assert "NOT_METADATA" not in sources + assert not any(path.startswith("blocked/") for path in sources) + + +def test_req_qml002_ac03_ac04_followed_metadata_keeps_lexical_scope_and_rejects_foreign_input( + tmp_path, directory_alias): + """Actual junctions/symlinks retain portable admitted names and exclude an external malformed module.""" + root, foreign = tmp_path / "repo", tmp_path / "foreign" + corpus(root) + foreign.mkdir() + for name in ("CMakeLists.txt", "qmldir", "Foreign.qml"): + (foreign / name).write_bytes(b"\xff\x00") + directory_alias(root / "linked module Ω", root / "qmltools") + directory_alias(root / "outside", foreign) + paths = {path.relative_to(root).as_posix() for path in collected(root, True)} + assert {"linked module Ω/qmldir", "linked module Ω/tools.pro", "linked module Ω/Hidden.qml"} <= paths + assert not any(path.startswith("outside/") for path in paths) + + +@pytest.mark.parametrize("follow", [False, True]) +def test_req_qml002_ac02_direct_cold_warm_facts_keep_qt_metadata_and_real_application_code( + tmp_path, follow): + """The facade preserves metadata/native joins while the copied installed hook contributes no facts.""" + root = tmp_path / "repo" + corpus(root) + cold = direct(root, tmp_path / "cold", follow) + warm = direct(root, tmp_path / "cold", follow) + assert_membership(cold) + assert normalized(cold) == normalized(warm) + + +@pytest.mark.parametrize("follow", [False, True]) +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac03_ac04_updates_preserve_exclusions_metadata_and_clean_parity( + tmp_path, monkeypatch, operation, follow): + """Real publication refreshes admitted metadata/code, ignores copied hook edits and is repeatable.""" + root = tmp_path / "repo" + corpus(root) + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + graph = run(root, monkeypatch, operation, follow_symlinks=follow) + assert_membership(graph) + before = snapshot(root / "graphify-out") + (root / INSTALLED).write_text("function replacementPoison() { return 9; }\n", encoding="utf-8") + assert normalized(run(root, monkeypatch, operation, [], follow_symlinks=follow)) == normalized(graph) + assert snapshot(root / "graphify-out") == before + metadata, source = root / "qmltools/qmldir", root / ORDINARY + metadata.write_text("module Public.Hidden\nHidden 1.0 Hidden.qml\nprefer :/hidden/\n", encoding="utf-8") + source.write_text("function applicationEdit() { return 11; }\n", encoding="utf-8") + updated = run(root, monkeypatch, operation, [metadata, source], follow_symlinks=follow) + assert_membership(updated) + assert any(qml_metadata(node).get("kind") == "prefer" + and qml_metadata(node).get("value") == ":/hidden/" + for _, node in updated.nodes(data=True) + if node.get("source_file") == "qmltools/qmldir") + assert normalized(updated) != normalized(graph) + assert normalized(updated) == normalized(direct(root, tmp_path / "clean", follow)) + accepted = snapshot(root / "graphify-out") + assert normalized(run(root, monkeypatch, operation, [], follow_symlinks=follow)) == normalized(updated) + assert snapshot(root / "graphify-out") == accepted + no_scratch(root / "graphify-out") + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml018_ac06_malformed_admitted_metadata_retains_cohort_and_recovers( + tmp_path, monkeypatch, operation): + """A real invalid UTF-8 manifest fails before publication; repair and repeat retain correct facts.""" + root = tmp_path / "repo" + corpus(root) + monkeypatch.chdir(root) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(root, monkeypatch, operation, follow_symlinks=True) + source, output = root / "qmltools/qmldir", root / "graphify-out" + original, before, cached = source.read_bytes(), snapshot(output), cache_bytes(output) + assert cached + source.write_bytes(b"\xff\x00") + if operation == "manual": + with pytest.raises(SystemExit) as rejected: + run(root, monkeypatch, operation, follow_symlinks=True) + assert rejected.value.code == 1 + else: + assert not _rebuild_code(root, changed_paths=[source], no_cluster=True, + follow_symlinks=True) + assert snapshot(output) == before and cache_bytes(output) == cached + no_scratch(output) + source.write_bytes(original) + repaired = run(root, monkeypatch, operation, [source], follow_symlinks=True) + assert normalized(repaired) == normalized(initial) + assert_membership(repaired) + assert normalized(repaired) == normalized(direct(root, tmp_path / "clean", True)) + accepted = snapshot(output) + assert normalized(run(root, monkeypatch, operation, [], follow_symlinks=True)) == normalized(repaired) + assert snapshot(output) == accepted diff --git a/tests/test_upstream_qt_export_integration.py b/tests/test_upstream_qt_export_integration.py new file mode 100644 index 0000000000..939c6d48c3 --- /dev/null +++ b/tests/test_upstream_qt_export_integration.py @@ -0,0 +1,100 @@ +"""INC-QML-49: preserved upstream links share Qt's durable export validation.""" +from __future__ import annotations + +import copy +import json + +import networkx as nx +import pytest + +import graphify.__main__ as mainmod +from graphify.export import to_json +from graphify.extractors.qml_facts import qml_metadata +from graphify.paths import load_node_link_graph +from tests.test_qt_json_direction import actual_graph, damage + + +def mixed_graph(root): + """Real QML facts and ordinary parallel transport exercise one output boundary.""" + graph, source, target = actual_graph(root) + # Reverse undirected insertion order to make raw storage differ from the + # accepted QML read direction; generic links have no Qt marker transport. + reversed_graph = nx.Graph() + reversed_graph.add_node(target, **graph.nodes[target]) + reversed_graph.add_nodes_from((key, data) for key, data in graph.nodes(data=True) if key != target) + reversed_graph.add_edges_from((v, u, copy.deepcopy(data)) for u, v, data in graph.edges(data=True)) + reversed_graph.add_node("ordinary_caller", label="caller", source_file="ordinary.py", file_type="code") + reversed_graph.add_node("ordinary_target", label="target", source_file="ordinary.py", file_type="code") + reversed_graph.add_edge("ordinary_caller", "ordinary_target", relation="calls", confidence="EXTRACTED") + links = copy.deepcopy(nx.node_link_data(reversed_graph, edges="links")["links"]) + links.append({"source": "ordinary_caller", "target": "ordinary_target", + "relation": "imports", "confidence": "EXTRACTED"}) + return reversed_graph, links, source, target + + +def assert_preserved(path, source, target): + """The durable file keeps both generic mechanisms and the actual QML proof.""" + payload = json.loads(path.read_text(encoding="utf-8")) + ordinary = [link for link in payload["links"] if link["source"] == "ordinary_caller"] + assert {link["relation"] for link in ordinary} == {"calls", "imports"} and len(ordinary) == 2 + read = next(link for link in payload["links"] if link.get("context") == "qml_binding_read") + assert (read["source"], read["target"]) == (source, target) + assert read["source_location"] and qml_metadata(read) + assert "_src" not in read and "_tgt" not in read + loaded = load_node_link_graph(path) + assert loaded.has_edge(source, target) + assert loaded.nodes[source]["source_file"] == "Main.qml" + return payload + + +def test_req_qml_010_ac02_original_parallel_links_retain_qml_direction(tmp_path): + """Upstream's original-link path preserves distinct relations and Qt source provenance.""" + graph, links, source, target = mixed_graph(tmp_path) + before = copy.deepcopy(nx.node_link_data(graph, edges="links")), copy.deepcopy(links) + path = tmp_path / "graph.json" + assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed") + assert_preserved(path, source, target) + assert (nx.node_link_data(graph, edges="links"), links) == before + accepted = path.read_bytes() + assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed") + assert path.read_bytes() == accepted + + +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("corruption", ["partial_source", "foreign_target", "list", "collapsed"]) +def test_req_qml_018_ac06_preserved_invalid_link_refuses_replacement(tmp_path, capsys, force, corruption): + """A corrupt original link cannot hide behind the simple graph's accepted edge.""" + graph, links, source, target = mixed_graph(tmp_path) + path = tmp_path / "previous.json" + assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed") + accepted, stamp = path.read_bytes(), path.stat().st_mtime_ns + malformed = copy.deepcopy(links) + read = next(link for link in malformed if link.get("context") == "qml_binding_read") + damage(read, corruption, source, target) + before = copy.deepcopy(malformed), copy.deepcopy(nx.node_link_data(graph, edges="links")) + assert to_json(graph, {}, str(path), original_links=malformed, force=force, built_at_commit="fixed") is False + assert path.read_bytes() == accepted and path.stat().st_mtime_ns == stamp + diagnostic = capsys.readouterr().err + assert "QT_EXPORT_DIRECTION" in diagnostic and "private-needle" not in diagnostic + assert (malformed, nx.node_link_data(graph, edges="links")) == before + assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed") + assert path.read_bytes() == accepted + assert_preserved(path, source, target) + + +def test_req_qml_018_ac04_real_recluster_keeps_parallel_and_qml_links(tmp_path, monkeypatch, capsys): + """The production offline CLI reloads and writes every original relationship twice.""" + graph, links, source, target = mixed_graph(tmp_path) + out = tmp_path / "graphify-out" + out.mkdir() + path = out / "graph.json" + assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed") + previous = assert_preserved(path, source, target) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "cluster-only", str(tmp_path), "--no-viz", "--no-label"]) + for _ in range(2): + mainmod.main() + current = assert_preserved(path, source, target) + assert current["links"] == previous["links"] + assert current["built_at_commit"] == "fixed" + assert "Done -" in capsys.readouterr().out diff --git a/tests/test_upstream_qt_path_integration.py b/tests/test_upstream_qt_path_integration.py new file mode 100644 index 0000000000..aaabd56250 --- /dev/null +++ b/tests/test_upstream_qt_path_integration.py @@ -0,0 +1,77 @@ +"""INC-QML-49: upstream qualified endpoint identity retains real Qt/QML paths.""" +from __future__ import annotations + +import json + +import pytest + +import graphify.__main__ as mainmod +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extractors.qt_cpp_facts import qt_metadata +from tests.test_qt_qml_integration import corpus + + +def native_path(root, directed=False): + """Actual registered C++ source supplies a native target and typed QML call.""" + result = corpus(root) + call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call") + source, target = call["source"], call["target"] + assert qt_metadata(call)["native_endpoint"]["canonical_target_id"] == target + graph = build_from_json(result, root=root, directed=directed) + source_query = f'{graph.nodes[source]["source_file"]}::{graph.nodes[source]["label"]}' + target_query = f'{graph.nodes[target]["source_file"]}::{graph.nodes[target]["label"]}' + # A same-named declaration in another file may never steal the explicit + # native query or add a route unrelated to the accepted registration. + graph.add_node("decoy", label=graph.nodes[target]["label"], source_file="other.hpp", file_type="code") + path = root / "graph.json" + assert to_json(graph, {}, str(path), built_at_commit="fixed") + return path, source, target, source_query, target_query + + +def run_path(monkeypatch, path, source, target, capsys, *flags): + """Exercise the public CLI; only its unrelated version notification is isolated.""" + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "path", source, target, "--graph", str(path), *flags]) + try: + mainmod.main() + except SystemExit as ended: + code = ended.code + else: + code = 0 + captured = capsys.readouterr() + return code, captured.out, captured.err + + +@pytest.mark.parametrize("directed", [False, True]) +def test_req_qml_010_ac03_qualified_native_path_preserves_direction(tmp_path, monkeypatch, capsys, directed): + """Qualified source names select the real QML-to-C++ edge through either graph mode.""" + path, source, target, source_query, target_query = native_path(tmp_path, directed) + accepted = path.read_bytes() + code, output, error = run_path(monkeypatch, path, source_query, target_query, capsys) + assert code == 0 and not error + assert "Shortest path (1 hops):" in output and "--calls [INFERRED]-->" in output + assert "qml_js_call" in output and "backend.hpp" in output + code, output, error = run_path(monkeypatch, path, target_query, source_query, capsys) + assert code == 0 and not error and "No directed path found" in output + code, output, error = run_path(monkeypatch, path, target_query, source_query, capsys, "--undirected") + assert code == 0 and not error and "<--calls [INFERRED]--" in output + code, output, error = run_path(monkeypatch, path, source, target, capsys) + assert code == 0 and not error and "Shortest path (1 hops):" in output + assert path.read_bytes() == accepted + + +def test_req_qml_018_ac04_duplicate_qualified_target_refuses_guess(tmp_path, monkeypatch, capsys): + """Two declarations in the same qualified file reject selection; exact ID still works.""" + path, source, target, source_query, target_query = native_path(tmp_path) + payload = json.loads(path.read_text(encoding="utf-8")) + original = next(node for node in payload["nodes"] if node["id"] == target) + payload["nodes"].append({**original, "id": "same_file_duplicate"}) + path.write_text(json.dumps(payload), encoding="utf-8") + accepted = path.read_bytes() + code, output, error = run_path(monkeypatch, path, source_query, target_query, capsys) + assert code == 1 and not output and "ambiguous" in error.lower() + assert target in error and "same_file_duplicate" in error + code, output, error = run_path(monkeypatch, path, source, target, capsys) + assert code == 0 and not error and "Shortest path (1 hops):" in output + assert path.read_bytes() == accepted diff --git a/tests/test_upstream_qt_shared_integration.py b/tests/test_upstream_qt_shared_integration.py new file mode 100644 index 0000000000..a60cd06262 --- /dev/null +++ b/tests/test_upstream_qt_shared_integration.py @@ -0,0 +1,155 @@ +"""INC-QML-49: upstream C++ union ownership coexists with Qt native facts.""" +from __future__ import annotations + +import json + +import pytest + +from graphify.build import build_from_json +from graphify.export import to_json +from graphify.extract import collect_files, extract, extract_cpp +from graphify.extractors.qml_facts import qml_metadata +from graphify.extractors.qt_cpp_facts import qt_metadata +from graphify.paths import load_node_link_graph +from tests.test_qt_final_incremental_parity import clean, normalized, run + + +HEADER = """#include +union Value { int integer; float real; int fetch() { return integer; } }; +struct Point { int x; }; +typedef union { int anonymous; } Hidden; +class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service) + Q_PROPERTY(int value READ value) +public: int value() const { return 1; } + Q_INVOKABLE int fetch() { return 2; } +}; +""" +BUILDS = [ + ("CMakeLists.txt", "qt_add_qml_module(app URI Public.Tools VERSION 1.0 " + "QML_FILES Main.qml SOURCES backend.h)\n"), + ("app.pro", "CONFIG += qmltypes\nQML_IMPORT_NAME = Public.Tools\n" + "QML_IMPORT_MAJOR_VERSION = 1\nHEADERS += backend.h\nDISTFILES += Main.qml\n"), +] + + +def project(root, build_name, build): + """A same-named union method is a real non-provider collision control.""" + sources = { + build_name: build, + "backend.h": HEADER, + "Main.qml": "import Public.Tools 1.0 as Tools\n" + "Tools.Service { property int displayed: value; " + "property int called: fetch() }\n", + } + for name, text in sources.items(): + (root / name).write_text(text, encoding="utf-8", newline="") + return root / "backend.h" + + +def declaration(graph, label): + matches = [(identity, data) for identity, data in graph.nodes(data=True) + if data.get("source_file") == "backend.h" and data.get("label") == label] + assert len(matches) == 1, (label, matches) + return matches[0] + + +def method(graph, owner): + """Generic display labels omit the class; stored ownership selects identity.""" + matches = [target for _, target, data in graph.edges(owner, data=True) + if graph.nodes[target].get("label") == ".fetch()" and + data.get("relation") == "method"] + assert len(matches) == 1 + return matches[0] + + +def assert_joint_facts(graph, member="integer"): + """Check source-owned generic relationships and exact Qt endpoint identity.""" + union, union_data = declaration(graph, "Value") + field, field_data = declaration(graph, member) + backend, _ = declaration(graph, "Backend") + union_fetch, backend_fetch = method(graph, union), method(graph, backend) + assert union_data["source_location"] == field_data["source_location"] == "L2" + assert declaration(graph, "Point")[1]["source_location"] == "L3" + assert any(target == field and data.get("relation") == "defines" + for _, target, data in graph.edges(union, data=True)) + assert graph.nodes[union_fetch]["source_location"] == "L2" + assert graph.nodes[backend_fetch]["source_location"] == "L8" + assert not any(data.get("label") in {"Hidden", "anonymous"} + for _, data in graph.nodes(data=True)) + calls = [(source, target, data) for source, target, data in graph.edges(data=True) + if data.get("context") == "qml_js_call"] + assert len(calls) == 1 + source, target, call = calls[0] + proof = qt_metadata(call)["native_endpoint"] + assert (call.get("_src", source), call.get("_tgt", target))[1] == backend_fetch + assert proof["canonical_target_id"] == backend_fetch != union_fetch + assert graph.nodes[proof["class_id"]]["label"] == "Backend" + assert any(qml_metadata(graph.nodes[item]).get("kind") == "qt_module" + for item in proof["evidence"]) + assert any(data.get("context") == "qml_binding_read" and + qt_metadata(data).get("native_endpoint") + for _, _, data in graph.edges(data=True)) + assert all(graph.nodes[endpoint].get("source_file") + for endpoint in (union, field, union_fetch, backend_fetch)) + return union, field, union_fetch, backend_fetch + + +@pytest.mark.parametrize("build_name,build", BUILDS) +def test_req_qml003_ac01_ac02_ac04_union_and_qt_native_cold_warm_reload( + tmp_path, monkeypatch, build_name, build): + """Real direct/facade parsing and warm replay retain both owners and spans.""" + source = project(tmp_path, build_name, build) + raw = extract_cpp(source) + assert not raw.get("parse_errors") + raw_labels = {node["label"] for node in raw["nodes"]} + assert {"Value", "integer", "real", ".fetch()"} <= raw_labels + assert sum(node["label"] == ".fetch()" for node in raw["nodes"]) == 2 + paths = collect_files(tmp_path, root=tmp_path) + cold = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert not cold["failed_sources"] and not cold["qml_failures"] + # The facade's generic C++ producer must really warm-hit; Qt's accepted + # overlay may still reparse the same source for its run-owned metadata. + import graphify.extract as extraction + observed, real_extract = [], extraction._safe_extract_with_xaml_root + + def observe(path, *args, **kwargs): + observed.append(path) + return real_extract(path, *args, **kwargs) + + monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe) + warm = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False) + assert source not in observed + assert not warm["failed_sources"] and not warm["qml_failures"] + graphs = [] + for name, facts in (("cold", cold), ("warm", warm)): + graph = build_from_json(facts, root=tmp_path, directed=True) + assert_joint_facts(graph) + output = tmp_path / f"{name}.json" + assert to_json(graph, {}, str(output), force=True) + restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))) + assert_joint_facts(restored) + graphs.append(restored) + assert normalized(graphs[0]) == normalized(graphs[1]) + + +@pytest.mark.parametrize("operation", ["manual", "watch"]) +def test_req_qml011_ac01_ac04_union_edit_removes_stale_member_keeps_native_endpoint( + tmp_path, monkeypatch, operation): + """A real generic union edit replaces only its member through both updates.""" + source = project(tmp_path, *BUILDS[0]) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1") + initial = run(tmp_path, monkeypatch, operation) + union, old_field, union_fetch, backend_fetch = assert_joint_facts(initial) + source.write_text(HEADER.replace("integer", "whole"), encoding="utf-8", newline="") + edited = run(tmp_path, monkeypatch, operation, [source]) + current_union, new_field, current_union_fetch, current_backend_fetch = assert_joint_facts(edited, "whole") + assert (current_union, current_union_fetch, current_backend_fetch) == (union, union_fetch, backend_fetch) + assert new_field != old_field and old_field not in edited + assert not any(old_field in (start, end) for start, end in edited.edges()) + fresh = clean(tmp_path, tmp_path / ".comparison-cache") + assert_joint_facts(fresh, "whole") + assert normalized(edited) == normalized(fresh) + accepted = (tmp_path / "graphify-out/graph.json").read_bytes() + assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(edited) + assert (tmp_path / "graphify-out/graph.json").read_bytes() == accepted diff --git a/tests/test_watch.py b/tests/test_watch.py index d13f4b362a..4505e31ac0 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -807,6 +807,9 @@ def test_graphify_root_preserves_absolute_when_user_supplied(tmp_path): ) +# Windows keeps the process CWD open and refuses its removal; the actual deleted +# directory fixtures belong to POSIX. Portable OS-error seams live separately. +@pytest.mark.skipif(os.name == "nt", reason="Windows cannot remove the active process CWD") def test_rebuild_code_deleted_cwd_without_repo_root_returns_false(tmp_path, monkeypatch, capsys): """Detached hooks can inherit a CWD that no longer exists. @@ -821,8 +824,8 @@ def test_rebuild_code_deleted_cwd_without_repo_root_returns_false(tmp_path, monk monkeypatch.delenv("GRAPHIFY_REPO_ROOT", raising=False) os.chdir(gone) - gone.rmdir() try: + gone.rmdir() assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")]) is False finally: os.chdir(old_cwd) @@ -831,6 +834,7 @@ def test_rebuild_code_deleted_cwd_without_repo_root_returns_false(tmp_path, monk assert "current working directory no longer exists" in out +@pytest.mark.skipif(os.name == "nt", reason="Windows cannot remove the active process CWD") def test_rebuild_code_deleted_cwd_uses_graphify_repo_root(tmp_path, monkeypatch): """GRAPHIFY_REPO_ROOT lets detached hook rebuilds recover from a deleted CWD.""" from graphify.watch import _rebuild_code @@ -844,8 +848,8 @@ def test_rebuild_code_deleted_cwd_uses_graphify_repo_root(tmp_path, monkeypatch) monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(corpus)) os.chdir(gone) - gone.rmdir() try: + gone.rmdir() assert _rebuild_code( Path("."), changed_paths=[Path("lib.py")], diff --git a/tests/test_watch_coowner_admission.py b/tests/test_watch_coowner_admission.py new file mode 100644 index 0000000000..f40e86f4ba --- /dev/null +++ b/tests/test_watch_coowner_admission.py @@ -0,0 +1,70 @@ +"""INC-QML-44: physical equality cannot override watch admission or semantic ownership.""" +from __future__ import annotations + +import json +import os + +import graphify.extract as extraction +from graphify.watch import _rebuild_code +from tests.test_qt_final_incremental_parity import normalized +from tests.test_watch_physical_coowners import published + + +def observed_inputs(monkeypatch, root): + """Observe real facade work while leaving parsing and publication intact.""" + observed, real_extract = [], extraction.extract + + def inputs(paths, *args, **kwargs): + paths = list(paths) + observed.append({path.relative_to(root).as_posix() for path in paths}) + return real_extract(paths, *args, **kwargs) + + monkeypatch.setattr(extraction, "extract", inputs) + return observed + + +def test_req_qml011_ac01_semantic_backed_hardlink_keeps_its_own_tier(monkeypatch, tmp_path): + """One physical edit refreshes the AST owner and preserves a semantic sibling verbatim.""" + root = tmp_path / "repo" + root.mkdir() + target, alias = root / "plain.md", root / "semantic.md" + target.write_text("# Previous\n\nText.\n", encoding="utf-8") + os.link(target, alias) + assert target.stat().st_ino == alias.stat().st_ino + assert _rebuild_code(root, no_cluster=True, acquire_lock=False) + path = root / "graphify-out/graph.json" + data = json.loads(path.read_text(encoding="utf-8")) + # Seed only an external semantic result, as the existing semantic watch + # fixtures do. Selection, AST reconciliation and persistence remain real. + data["nodes"].append({"id": "semantic_concept", "label": "Retained concept", + "file_type": "concept", "source_file": "semantic.md"}) + path.write_text(json.dumps(data), encoding="utf-8") + before, _ = normalized(published(root)) + sibling = {identity: value for identity, value in before.items() + if published(root).nodes[identity].get("source_file") == "semantic.md"} + observed = observed_inputs(monkeypatch, root) + target.write_text("# Updated\n\nChanged.\n", encoding="utf-8") + assert _rebuild_code(root, changed_paths=[target], no_cluster=True, acquire_lock=False) + actual = published(root) + after, _ = normalized(actual) + assert observed == [{"plain.md"}] + assert sibling == {identity: value for identity, value in after.items() + if actual.nodes[identity].get("source_file") == "semantic.md"} + assert any(node.get("label") == "Updated" for _, node in actual.nodes(data=True) + if node.get("source_file") == "plain.md") + + +def test_req_qml011_ac01_excluded_hardlink_cannot_reenter_the_corpus(monkeypatch, tmp_path): + """A physically equal excluded source is absent from work and durable output.""" + root = tmp_path / "repo" + root.mkdir() + target, alias = root / "accepted.py", root / "excluded.py" + target.write_text("def previous():\n return 1\n", encoding="utf-8") + os.link(target, alias) + (root / ".graphifyignore").write_text("excluded.py\n", encoding="utf-8") + assert _rebuild_code(root, no_cluster=True, acquire_lock=False) + observed = observed_inputs(monkeypatch, root) + target.write_text("def updated():\n return 2\n", encoding="utf-8") + assert _rebuild_code(root, changed_paths=[target], no_cluster=True, acquire_lock=False) + assert observed == [{"accepted.py"}] + assert all(node.get("source_file") != "excluded.py" for _, node in published(root).nodes(data=True)) diff --git a/tests/test_watch_physical_coowners.py b/tests/test_watch_physical_coowners.py new file mode 100644 index 0000000000..cf0bb1069f --- /dev/null +++ b/tests/test_watch_physical_coowners.py @@ -0,0 +1,280 @@ +"""INC-QML-44: physical co-owner invalidation through real watch publication.""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from graphify.build import build_from_json +import graphify.extract as extraction +import graphify.qt_analysis_state as state +import graphify.qt_incremental as policy +from graphify.paths import load_node_link_graph +from graphify.watch import _rebuild_code +import graphify.watch_coowners as coowners +from tests.test_qt_final_incremental_parity import normalized, project, unrelated +from tests.test_qt_product_publication import no_scratch, snapshot + + +SOURCE = b"def helper():\n return 7\n\ndef caller():\n return helper()\n" + + +def owners(tmp_path, request, kind, *, mixed=False): + """Only an admitted directory alias or real hardlink shares source content.""" + root = tmp_path / "repo" + (root / "real").mkdir(parents=True) + if mixed: + project(root) + else: + (root / "keep.py").write_bytes(SOURCE.replace(b"helper", b"independent")) + target = root / "real/member.py" + target.write_bytes(SOURCE) + if kind == "directory": + request.getfixturevalue("directory_alias")(root / "linked", target.parent) + else: + (root / "linked").mkdir() + os.link(target, root / "linked/member.py") + alias = root / "linked/member.py" + assert target.stat().st_ino and target.stat().st_dev + assert (target.stat().st_dev, target.stat().st_ino) == (alias.stat().st_dev, alias.stat().st_ino) + return root, target, alias + + +# Reuse the real link fixture, not its selection or graph behavior. +from tests.test_source_alias_provenance import directory_alias # noqa: E402,F401 + + +def published(root): + return load_node_link_graph(json.loads((root / "graphify-out/graph.json").read_text(encoding="utf-8"))) + + +def complete(root, cache): + paths = extraction.collect_files(root, follow_symlinks=True, root=root) + result = extraction.extract(paths, root=root, cache_root=cache, parallel=False) + assert not result["failed_sources"] and not result["qml_failures"] + return build_from_json(result, root=root) + + +def cache_bytes(output): + """Compare actual cache payloads, not only the published graph cohort.""" + return {path.relative_to(output).as_posix(): path.read_bytes() + for path in (output / "cache").rglob("*") if path.is_file()} + + +@pytest.mark.parametrize("kind", ["directory", "hardlink"]) +@pytest.mark.parametrize("mixed", [False, True]) +def test_req_qml011_ac01_ac03_qml020_ac03_one_owner_edit_refreshes_shared_content( + tmp_path, request, monkeypatch, kind, mixed): + """One notification retires both old APIs; unrelated/native facts remain stable.""" + root, target, alias = owners(tmp_path, request, kind, mixed=mixed) + assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False) + before = published(root) + independent = unrelated(before) + observed, real_extract = [], extraction.extract + + def inputs(paths, *args, **kwargs): + paths = list(paths) + observed.append({path.relative_to(root).as_posix() for path in paths}) + return real_extract(paths, *args, **kwargs) + + monkeypatch.setattr(extraction, "extract", inputs) + target.write_bytes(SOURCE.replace(b"helper", b"updated")) + assert _rebuild_code(root, changed_paths=[alias], follow_symlinks=True, no_cluster=True, acquire_lock=False) + actual = published(root) + assert observed == [{"real/member.py", "linked/member.py"}] + assert "real_member_helper" not in actual and "linked_member_helper" not in actual + assert {"real_member_updated", "linked_member_updated"} <= set(actual) + assert unrelated(actual) == independent + cold = complete(root, tmp_path / "full-cache") + assert normalized(actual) == normalized(cold) + assert normalized(complete(root, tmp_path / "full-cache")) == normalized(cold) + accepted = snapshot(root / "graphify-out") + observed.clear() + assert _rebuild_code(root, changed_paths=[], follow_symlinks=True, no_cluster=True, acquire_lock=False) + assert not observed and snapshot(root / "graphify-out") == accepted + + +@pytest.mark.parametrize("kind", ["directory", "hardlink"]) +def test_req_qml011_ac03_normal_rename_and_deletion_retire_only_absent_owners( + tmp_path, request, kind): + """A normal disappearance before detection is deletion, not an identity failure.""" + root, target, _ = owners(tmp_path, request, kind) + assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False) + renamed = target.with_name("renamed.py") + target.rename(renamed) + assert _rebuild_code(root, changed_paths=[target, renamed], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + actual = published(root) + assert "real_member" not in actual and "real_renamed" in actual + assert normalized(actual) == normalized(complete(root, tmp_path / "rename-cache")) + renamed.unlink() + assert _rebuild_code(root, changed_paths=[renamed], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + actual = published(root) + assert "real_renamed" not in actual + assert ("linked_member" in actual) == (kind == "hardlink") + assert normalized(actual) == normalized(complete(root, tmp_path / "delete-cache")) + + +@pytest.mark.parametrize("operation,fault", [("resolve", OSError), ("resolve", RuntimeError), + ("stat", OSError), ("stat", RuntimeError), + ("disappearance", FileNotFoundError)]) +def test_req_qml012_ac01_ac02_identity_failure_retains_recovers_and_repeats( + tmp_path, request, monkeypatch, capsys, operation, fault): + """A real admitted lookup failure preserves every published product and hides its body.""" + root, target, alias = owners(tmp_path, request, "directory", mixed=True) + assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False) + output = root / "graphify-out" + before = snapshot(output) + cached = cache_bytes(output) + assert cached + target.write_bytes(SOURCE.replace(b"helper", b"updated")) + real_identity = coowners.physical_identity + raw_body = "private backend response token=must-never-appear" + observed = [] + + def failed_identity(path, anchor): + if path != alias: + return real_identity(path, anchor) + observed.append(path) + if operation == "disappearance": + # Actual race after admitted detection, before verified co-ownership. + target.unlink() + return real_identity(path, anchor) + real_operation = getattr(Path, operation) + + def unavailable(source, *args, **kwargs): + if source == alias: + raise fault(raw_body) + return real_operation(source, *args, **kwargs) + + with monkeypatch.context() as lookup: + lookup.setattr(Path, operation, unavailable) + return real_identity(path, anchor) + + with monkeypatch.context() as failed: + failed.setattr(coowners, "physical_identity", failed_identity) + assert not _rebuild_code(root, changed_paths=[alias], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + assert observed and snapshot(output) == before + assert cache_bytes(output) == cached + captured = capsys.readouterr() + text = captured.out + captured.err + assert "WATCH_SOURCE_IDENTITY_FAILED" in text and 'source="linked/member.py"' in text + assert raw_body not in text and str(root) not in text.split("Rebuild failed:")[-1] + no_scratch(output) + target.write_bytes(SOURCE.replace(b"helper", b"updated")) + assert _rebuild_code(root, changed_paths=[alias], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + assert normalized(published(root)) == normalized(complete(root, tmp_path / "repair-cache")) + accepted = snapshot(output) + assert _rebuild_code(root, changed_paths=[alias], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + assert snapshot(output) == accepted + no_scratch(output) + + +@pytest.mark.parametrize("label", ["a\n.py", "a\x7f.py", "a\u2028.py", "x" * 158 + ".py"]) +def test_req_qml012_ac04_unsafe_or_long_missing_identity_context_is_empty(tmp_path, label): + """Rejected lookup context is bounded independently of backend path/error text.""" + with pytest.raises(ValueError) as rejected: + coowners.physical_identity(tmp_path / label, tmp_path) + assert str(rejected.value) == ('WATCH_SOURCE_IDENTITY_FAILED: source=""; admitted source ' + 'identity unavailable; prior products retained; repair and retry') + + +def test_req_qml011_ac01_selection_cannot_discover_foreign_or_unadmitted_coowners(tmp_path): + """A supplied change cannot bypass admission; a true foreign corpus path is rejected.""" + root, foreign = tmp_path / "repo", tmp_path / "foreign.py" + root.mkdir() + local = root / "local.py" + local.write_bytes(SOURCE) + foreign.write_bytes(SOURCE) + with pytest.raises(ValueError, match="WATCH_SOURCE_IDENTITY_FAILED"): + coowners.expand_changed_coowners([foreign], [local], root=root) + with pytest.raises(ValueError, match="WATCH_SOURCE_IDENTITY_FAILED"): + coowners.expand_changed_coowners([local], [local, foreign], root=root) + with pytest.raises(ValueError, match="WATCH_SOURCE_IDENTITY_FAILED"): + coowners.expand_changed_coowners([local], [local, root], root=root) + assert coowners.expand_changed_coowners([], [foreign], root=root) == [] + assert local.read_bytes() == foreign.read_bytes() == SOURCE + + +def test_req_qml011_ac01_missing_inode_identity_uses_contained_path_without_false_joins( + tmp_path, request, monkeypatch): + """A filesystem without usable inode IDs still proves only resolved-path aliases.""" + root, target, alias = owners(tmp_path, request, "directory") + separate = root / "separate.py" + separate.write_bytes(SOURCE) + original_stat = Path.stat + + def unidentified(path, *args, **kwargs): + value = list(original_stat(path, *args, **kwargs)) + value[1] = value[2] = 0 + return os.stat_result(value) + + monkeypatch.setattr(Path, "stat", unidentified) + assert coowners.expand_changed_coowners([alias], [target, alias, separate], root=root) == [alias, target] + + +def test_req_qml012_ac04_context_quotes_delimiters_and_preserves_160_character_boundary(tmp_path): + """Unavailable path labels remain one bounded quoted field, never backend instructions.""" + for label in ('a"; delimiter.py', "x" * 157 + ".py"): + with pytest.raises(ValueError) as rejected: + coowners.physical_identity(tmp_path / label, tmp_path) + text = str(rejected.value) + assert f"source={json.dumps(label, ensure_ascii=True)};" in text + assert len(label) <= coowners.SOURCE_CONTEXT_LIMIT and "\n" not in text + assert str(tmp_path) not in text + + +def test_req_qml011_ac03_ac04_qml020_ac03_policy20_refresh_retains_repairs_and_repeats( + tmp_path, request, monkeypatch, capsys): + """An unchanged prior Qt cohort refreshes both owners; stamp failure cannot publish it.""" + root, _, _ = owners(tmp_path, request, "hardlink", mixed=True) + with monkeypatch.context() as prior: + prior.setattr(policy, "QT_POLICY_VERSION", 20) + assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False) + output = root / "graphify-out" + before, initial = snapshot(output), normalized(published(root)) + fingerprint = state.read_qt_fingerprint(output) + observed, real_extract = [], extraction.extract + + def inputs(paths, *args, **kwargs): + paths = list(paths) + observed.append({path.relative_to(root).as_posix() for path in paths}) + return real_extract(paths, *args, **kwargs) + + monkeypatch.setattr(extraction, "extract", inputs) + staged, real_commit = [], state.commit_qt_analysis + + def stage_then_fail(*args, **kwargs): + real_commit(*args, **kwargs) + staged.append(True) + raise OSError("public co-owner policy upgrade publication failure") + + with monkeypatch.context() as failed: + failed.setattr(state, "commit_qt_analysis", stage_then_fail) + assert not _rebuild_code(root, changed_paths=[], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + assert staged == [True] and snapshot(output) == before + assert state.read_qt_fingerprint(output) == fingerprint + assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out + required = {"real/member.py", "linked/member.py", "Main.qml", "backend.h", "loader.cpp"} + assert observed and required <= set.union(*observed) + no_scratch(output) + observed.clear() + assert _rebuild_code(root, changed_paths=[], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + assert normalized(published(root)) == initial + assert state.read_qt_fingerprint(output) != fingerprint + assert observed and required <= set.union(*observed) + assert normalized(published(root)) == normalized(complete(root, tmp_path / "upgrade-cache")) + accepted = snapshot(output) + observed.clear() + assert _rebuild_code(root, changed_paths=[], follow_symlinks=True, + no_cluster=True, acquire_lock=False) + assert not any(observed) and snapshot(output) == accepted + no_scratch(output) diff --git a/tools/skillgen/expected/graphify__skill-agents.md b/tools/skillgen/expected/graphify__skill-agents.md index 3f7fade819..87d10b0802 100644 --- a/tools/skillgen/expected/graphify__skill-agents.md +++ b/tools/skillgen/expected/graphify__skill-agents.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-aider.md b/tools/skillgen/expected/graphify__skill-aider.md index 6e459945af..f873f01a60 100644 --- a/tools/skillgen/expected/graphify__skill-aider.md +++ b/tools/skillgen/expected/graphify__skill-aider.md @@ -195,10 +195,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -208,7 +216,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('.graphify_ast.json').write_text(json.dumps(result, indent=2)) print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: @@ -766,6 +779,10 @@ The graph is the map. Your job after the pipeline is to be the guide. Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skill-amp.md b/tools/skillgen/expected/graphify__skill-amp.md index 3f7fade819..87d10b0802 100644 --- a/tools/skillgen/expected/graphify__skill-amp.md +++ b/tools/skillgen/expected/graphify__skill-amp.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-claw.md b/tools/skillgen/expected/graphify__skill-claw.md index 2eca2a295f..1f4d24162f 100644 --- a/tools/skillgen/expected/graphify__skill-claw.md +++ b/tools/skillgen/expected/graphify__skill-claw.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-codex.md b/tools/skillgen/expected/graphify__skill-codex.md index a126527fd7..d38dec09e9 100644 --- a/tools/skillgen/expected/graphify__skill-codex.md +++ b/tools/skillgen/expected/graphify__skill-codex.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-copilot.md b/tools/skillgen/expected/graphify__skill-copilot.md index 2eca2a295f..1f4d24162f 100644 --- a/tools/skillgen/expected/graphify__skill-copilot.md +++ b/tools/skillgen/expected/graphify__skill-copilot.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-devin.md b/tools/skillgen/expected/graphify__skill-devin.md index 9bd1f0dc73..ac25fa6b6e 100644 --- a/tools/skillgen/expected/graphify__skill-devin.md +++ b/tools/skillgen/expected/graphify__skill-devin.md @@ -208,10 +208,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -221,7 +229,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2)) print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: @@ -905,6 +918,10 @@ fi Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skill-droid.md b/tools/skillgen/expected/graphify__skill-droid.md index b1eeb4925a..df8b4bb67d 100644 --- a/tools/skillgen/expected/graphify__skill-droid.md +++ b/tools/skillgen/expected/graphify__skill-droid.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-kilo.md b/tools/skillgen/expected/graphify__skill-kilo.md index 387563de43..cb4c434985 100644 --- a/tools/skillgen/expected/graphify__skill-kilo.md +++ b/tools/skillgen/expected/graphify__skill-kilo.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-kiro.md b/tools/skillgen/expected/graphify__skill-kiro.md index 2eca2a295f..1f4d24162f 100644 --- a/tools/skillgen/expected/graphify__skill-kiro.md +++ b/tools/skillgen/expected/graphify__skill-kiro.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-opencode.md b/tools/skillgen/expected/graphify__skill-opencode.md index 144378e65a..03baadef09 100644 --- a/tools/skillgen/expected/graphify__skill-opencode.md +++ b/tools/skillgen/expected/graphify__skill-opencode.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-pi.md b/tools/skillgen/expected/graphify__skill-pi.md index 2eca2a295f..1f4d24162f 100644 --- a/tools/skillgen/expected/graphify__skill-pi.md +++ b/tools/skillgen/expected/graphify__skill-pi.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-trae.md b/tools/skillgen/expected/graphify__skill-trae.md index 36706e3d7c..51273ca71c 100644 --- a/tools/skillgen/expected/graphify__skill-trae.md +++ b/tools/skillgen/expected/graphify__skill-trae.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-vscode.md b/tools/skillgen/expected/graphify__skill-vscode.md index 6191a4ca6d..00a2ba94cc 100644 --- a/tools/skillgen/expected/graphify__skill-vscode.md +++ b/tools/skillgen/expected/graphify__skill-vscode.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill-windows.md b/tools/skillgen/expected/graphify__skill-windows.md index 456244a246..64bac5060c 100644 --- a/tools/skillgen/expected/graphify__skill-windows.md +++ b/tools/skillgen/expected/graphify__skill-windows.md @@ -205,10 +205,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```powershell @' import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -218,7 +226,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8") print(f'AST: {len(result["nodes"])} nodes, {len(result["edges"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skill.md b/tools/skillgen/expected/graphify__skill.md index 2eca2a295f..1f4d24162f 100644 --- a/tools/skillgen/expected/graphify__skill.md +++ b/tools/skillgen/expected/graphify__skill.md @@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/expected/graphify__skills__agents__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__agents__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__agents__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__agents__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__agents__references__update.md b/tools/skillgen/expected/graphify__skills__agents__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__agents__references__update.md +++ b/tools/skillgen/expected/graphify__skills__agents__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__amp__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__amp__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__amp__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__amp__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__amp__references__update.md b/tools/skillgen/expected/graphify__skills__amp__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__amp__references__update.md +++ b/tools/skillgen/expected/graphify__skills__amp__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__claude__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__claude__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__claude__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__claude__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__claude__references__update.md b/tools/skillgen/expected/graphify__skills__claude__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__claude__references__update.md +++ b/tools/skillgen/expected/graphify__skills__claude__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__claw__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__claw__references__extraction-spec.md index 4b278b28d3..0398cfeeee 100644 --- a/tools/skillgen/expected/graphify__skills__claw__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__claw__references__extraction-spec.md @@ -14,6 +14,7 @@ Rules: - INFERRED: reasonable inference (shared structure, implied dependency) - AMBIGUOUS: uncertain — flag it, do not omit - Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language. +- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls. - Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. - Image files: use vision — understand what the image IS, not just OCR - DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. @@ -24,6 +25,8 @@ Rules: Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Output exactly this JSON (no other text): {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__claw__references__update.md b/tools/skillgen/expected/graphify__skills__claw__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__claw__references__update.md +++ b/tools/skillgen/expected/graphify__skills__claw__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__codex__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__codex__references__extraction-spec.md index 4b278b28d3..0398cfeeee 100644 --- a/tools/skillgen/expected/graphify__skills__codex__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__codex__references__extraction-spec.md @@ -14,6 +14,7 @@ Rules: - INFERRED: reasonable inference (shared structure, implied dependency) - AMBIGUOUS: uncertain — flag it, do not omit - Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language. +- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls. - Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. - Image files: use vision — understand what the image IS, not just OCR - DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. @@ -24,6 +25,8 @@ Rules: Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Output exactly this JSON (no other text): {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__codex__references__update.md b/tools/skillgen/expected/graphify__skills__codex__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__codex__references__update.md +++ b/tools/skillgen/expected/graphify__skills__codex__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__copilot__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__copilot__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__copilot__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__copilot__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__copilot__references__update.md b/tools/skillgen/expected/graphify__skills__copilot__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__copilot__references__update.md +++ b/tools/skillgen/expected/graphify__skills__copilot__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__droid__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__droid__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__droid__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__droid__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__droid__references__update.md b/tools/skillgen/expected/graphify__skills__droid__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__droid__references__update.md +++ b/tools/skillgen/expected/graphify__skills__droid__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__kilo__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__kilo__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__kilo__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__kilo__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__kilo__references__update.md b/tools/skillgen/expected/graphify__skills__kilo__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__kilo__references__update.md +++ b/tools/skillgen/expected/graphify__skills__kilo__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__kiro__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__kiro__references__extraction-spec.md index 4b278b28d3..0398cfeeee 100644 --- a/tools/skillgen/expected/graphify__skills__kiro__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__kiro__references__extraction-spec.md @@ -14,6 +14,7 @@ Rules: - INFERRED: reasonable inference (shared structure, implied dependency) - AMBIGUOUS: uncertain — flag it, do not omit - Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language. +- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls. - Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. - Image files: use vision — understand what the image IS, not just OCR - DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. @@ -24,6 +25,8 @@ Rules: Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Output exactly this JSON (no other text): {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__kiro__references__update.md b/tools/skillgen/expected/graphify__skills__kiro__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__kiro__references__update.md +++ b/tools/skillgen/expected/graphify__skills__kiro__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__opencode__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__opencode__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__opencode__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__opencode__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__opencode__references__update.md b/tools/skillgen/expected/graphify__skills__opencode__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__opencode__references__update.md +++ b/tools/skillgen/expected/graphify__skills__opencode__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__pi__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__pi__references__extraction-spec.md index 4b278b28d3..0398cfeeee 100644 --- a/tools/skillgen/expected/graphify__skills__pi__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__pi__references__extraction-spec.md @@ -14,6 +14,7 @@ Rules: - INFERRED: reasonable inference (shared structure, implied dependency) - AMBIGUOUS: uncertain — flag it, do not omit - Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language. +- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls. - Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. - Image files: use vision — understand what the image IS, not just OCR - DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. @@ -24,6 +25,8 @@ Rules: Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Output exactly this JSON (no other text): {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__pi__references__update.md b/tools/skillgen/expected/graphify__skills__pi__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__pi__references__update.md +++ b/tools/skillgen/expected/graphify__skills__pi__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__trae__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__trae__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__trae__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__trae__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__trae__references__update.md b/tools/skillgen/expected/graphify__skills__trae__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__trae__references__update.md +++ b/tools/skillgen/expected/graphify__skills__trae__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__vscode__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__vscode__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__vscode__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__vscode__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__vscode__references__update.md b/tools/skillgen/expected/graphify__skills__vscode__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__vscode__references__update.md +++ b/tools/skillgen/expected/graphify__skills__vscode__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/expected/graphify__skills__windows__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__windows__references__extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/expected/graphify__skills__windows__references__extraction-spec.md +++ b/tools/skillgen/expected/graphify__skills__windows__references__extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/expected/graphify__skills__windows__references__update.md b/tools/skillgen/expected/graphify__skills__windows__references__update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/expected/graphify__skills__windows__references__update.md +++ b/tools/skillgen/expected/graphify__skills__windows__references__update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/fragments/core/aider.md b/tools/skillgen/fragments/core/aider.md index 6e459945af..f873f01a60 100644 --- a/tools/skillgen/fragments/core/aider.md +++ b/tools/skillgen/fragments/core/aider.md @@ -195,10 +195,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -208,7 +216,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('.graphify_ast.json').write_text(json.dumps(result, indent=2)) print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: @@ -766,6 +779,10 @@ The graph is the map. Your job after the pipeline is to be the guide. Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/fragments/core/core.md b/tools/skillgen/fragments/core/core.md index c527a12563..364fe9a275 100644 --- a/tools/skillgen/fragments/core/core.md +++ b/tools/skillgen/fragments/core/core.md @@ -130,10 +130,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -143,7 +151,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files, cache_root=Path('INPUT_PATH')) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: diff --git a/tools/skillgen/fragments/core/devin.md b/tools/skillgen/fragments/core/devin.md index 9bd1f0dc73..ac25fa6b6e 100644 --- a/tools/skillgen/fragments/core/devin.md +++ b/tools/skillgen/fragments/core/devin.md @@ -208,10 +208,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin For any code files detected, run AST extraction in parallel with Part B subagents: +[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools. + +[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup. + +[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json from graphify.extract import collect_files, extract +from graphify.qml_safety import require_complete_qml +from graphify.qt_analysis_state import inspect_qt_analysis from pathlib import Path import json @@ -221,7 +229,12 @@ for f in detect.get('files', {}).get('code', []): code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)]) if code_files: - result = extract(code_files) + scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve() + scan_root = scan_root.parent if scan_root.is_file() else scan_root + qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files) + result = extract(code_files, root=scan_root, cache_root=scan_root, + qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt) + require_complete_qml(result, code_files, operation='skill AST', root=scan_root) Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2)) print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: @@ -905,6 +918,10 @@ fi Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/fragments/references/shared/extraction-spec-compact.md b/tools/skillgen/fragments/references/shared/extraction-spec-compact.md index 4b278b28d3..0398cfeeee 100644 --- a/tools/skillgen/fragments/references/shared/extraction-spec-compact.md +++ b/tools/skillgen/fragments/references/shared/extraction-spec-compact.md @@ -14,6 +14,7 @@ Rules: - INFERRED: reasonable inference (shared structure, implied dependency) - AMBIGUOUS: uncertain — flag it, do not omit - Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language. +- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls. - Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. - Image files: use vision — understand what the image IS, not just OCR - DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. @@ -24,6 +25,8 @@ Rules: Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Output exactly this JSON (no other text): {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/fragments/references/shared/extraction-spec.md b/tools/skillgen/fragments/references/shared/extraction-spec.md index 388df7674f..a8bb52de70 100644 --- a/tools/skillgen/fragments/references/shared/extraction-spec.md +++ b/tools/skillgen/fragments/references/shared/extraction-spec.md @@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected. Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them. +[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it. +[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above. + Generate the extraction JSON matching this schema exactly: {"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0} diff --git a/tools/skillgen/fragments/references/shared/update.md b/tools/skillgen/fragments/references/shared/update.md index 3632fd4126..a917e2224b 100644 --- a/tools/skillgen/fragments/references/shared/update.md +++ b/tools/skillgen/fragments/references/shared/update.md @@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. +[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch. + +[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery. + ```bash $(cat graphify-out/.graphify_python) -c " import sys, json diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py index 6c30a08475..b90cd4a7f1 100644 --- a/tools/skillgen/gen.py +++ b/tools/skillgen/gen.py @@ -1180,6 +1180,32 @@ def _is_watch_injection_fix_line(line: str) -> bool: ) +def _is_qt_qml_source_guidance_line(line: str) -> bool: + """Allow only the reviewed Qt source runbook additions, never a prefix class. + + Keep the frozen v8 comparison intact. Exact prose and the complete AST root/ + pre-publication gate footprint are listed here; unrelated Qt-prefixed advice + or arbitrary extraction edits must still fail the monolith roundtrip. + """ + return line.strip() in { + "[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.", + "[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.", + "[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.", + "[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.", + "[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.", + "from graphify.qml_safety import require_complete_qml", + "from graphify.qt_analysis_state import inspect_qt_analysis", + "scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()", + "scan_root = scan_root.parent if scan_root.is_file() else scan_root", + "qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)", + "result = extract(code_files, root=scan_root, cache_root=scan_root,", + "qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)", + "require_complete_qml(result, code_files, operation='skill AST', root=scan_root)", + # The replaced v8 AST call. Split-core cache call is not a monolith line. + "result = extract(code_files)", + } + + # Every line that may differ between a rendered monolith and its pristine v8 # baseline. Each predicate documents one sanctioned change-class; a blank line is # allowed because the multi-line fix blocks insert spacing. Anything else failing @@ -1203,6 +1229,7 @@ def _is_watch_injection_fix_line(line: str) -> bool: _is_community_label_export_fix_line, _is_step1_root_marker_fix_line, _is_watch_injection_fix_line, + _is_qt_qml_source_guidance_line, ) diff --git a/uv.lock b/uv.lock index 0e90baef91..36127389da 100644 --- a/uv.lock +++ b/uv.lock @@ -1238,6 +1238,9 @@ postgres = [ { name = "psycopg", extra = ["binary"] }, { name = "tree-sitter-sql" }, ] +qml = [ + { name = "tree-sitter-language-pack" }, +] r = [ { name = "tree-sitter-language-pack" }, ] @@ -1368,6 +1371,7 @@ requires-dist = [ { name = "tree-sitter-kotlin", specifier = ">=1.0,<2.0" }, { name = "tree-sitter-language-pack", marker = "extra == 'all'", specifier = "==0.11.0" }, { name = "tree-sitter-language-pack", marker = "extra == 'erlang'", specifier = "==0.11.0" }, + { name = "tree-sitter-language-pack", marker = "extra == 'qml'", specifier = "==0.11.0" }, { name = "tree-sitter-language-pack", marker = "extra == 'r'", specifier = "==0.11.0" }, { name = "tree-sitter-lua", specifier = ">=0.2,<0.6" }, { name = "tree-sitter-objc", specifier = ">=3.0,<4.0" }, @@ -1397,7 +1401,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.7.4" }, { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.7.4" }, ] -provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "robot", "vbnet", "r", "erlang", "solidity", "all"] +provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "robot", "vbnet", "r", "qml", "erlang", "solidity", "all"] [package.metadata.requires-dev] dev = [