Files
Just-DataInspector/Tools/structcheck.ps1
T
DeepSeek V4.1 Flash 9cf6b2fedc 把三处功能要求也变成可复跑的检查(structcheck 93 条 + A/B 反证)
背景
----
「点组名不折起」「组名改名框不吞项数」「SignalData 去掉 displayName」
三处用户要求此前只有散文描述与已压缩的历史记录。本提交给它们补上
(a) 源码级断言、(b) A/B 反证、(c) 运行时断言三类证据。

新增 / 修改
-----------
- Tools/structcheck.ps1 新增第 [3] 组「行为与注释不变量」(31 条断言):
  * 折起热区:RowHitPart 产出专用 RowPart::GROUP_TITLE,其矩形正好是「组N」标签矩形;
    单击门限 `if (part != RowPart::DISCLOSURE && part != RowPart::GROUP_TITLE)` 全文件恰好 1 处;
    ToggleGroupFromRow 恰好 2 个入口(单击门限 + 双击折叠三角);
    双击非名称本体(`if (part != RowPart::BODY) return false;`)不进入改名。
  * 改名框:右边界必须是 `statsRight - statsWidth - 6*scale`,且全文件不得出现 `right = statsRight;`;
    项数宽度必须来自 GroupStatsText。
  * 数据模型:SignalData / SignalGroup 各只声明 1 个字符串字段且名为 name;
    RenameSignal / RenameGroup 存在;全仓 displayName / Title() / Set*DisplayName 残留 0 处。
  * 注释核验:过时措辞(文件下标 / 所属文件下标 / 原信号所属文件下标 / 文件分组 / 原文件下标 /
    文件序号)全仓 0 处;已修正注释确实存在(例如 DerivedSignalRequest::groupIndex →
    「原信号所属分组下标」)。
- Tools/structcheck_phrases.txt(新增,UTF-8):中文短语表,供 .ps1 以 UTF8Encoding($false)
  读入后逐条断言(forbid / require)。⚠️ 中文不能直接写进 .ps1 —— PowerShell 5.1 按 ANSI 读脚本。
- 断言总数 62 → 93;退出码仍为 0/1,可直接接 CI。

A/B 反证(判别力)
------------------
回退两处 UI 修复(`if (part != DISCLOSURE && part != GROUP_TITLE)` → `if (false)`;
`right = statsRight - statsWidth - 6*scale;` → `right = statsRight;`):
  * panelanim.exe   147/156(9 项如实失败)→ 恢复后 156/156
    失败文案直接点名需求:the group rename box stops before the item counter /
    clicking the group name does not start collapsing / does not collapse the group ...
  * structcheck     90/93(3 项如实失败)→ 恢复后 93/93
    single-click toggle gate accepts only DISCLOSURE / GROUP_TITLE (found 0) /
    rename box right edge excludes the item counter / rename box never extends over the counter

运行时断言(数据层,datacheck 69/69)
-------------------------------------
  Check(set.RenameSignal(groupIndex, 0, L"改名") && set.Resolve(SignalRef{0,0})->name == L"改名",
        "signal rename writes the single name field");
  Check(set.RenameGroup(groupIndex, L"我的组") && set.GroupAt(0)->name == L"我的组",
        "group rename writes the single name field");
  Check(!set.RenameSignal(groupIndex, 0, L"") && ..., "empty rename is rejected (name is never blanked)");
即重命名只写一个 name 字段(编译期已无 displayName 可写),空名被拒。

文档
----
- Documents/refactor_report_2026-10-16.md:新增 §9.7(三处功能要求的源码断言 + A/B + 运行时断言);
  §1.3 补完整 A/B 实测;§9.4 验证表与 §9.5 变更清单同步(62 → 93)。
- Documents/agent_mem.md:§9.37 开头补可复跑证据块;§11.2 第 96 条扩写为三组 + 中文短语数据文件的做法;
  变更日志 d2e6fdd 行的结构检查描述更新为 93 条与 A/B 数据。

验证
----
make release 零警告,Target.exe 764 416 B;
structcheck 93/93;verify 49/49;datacheck 69/69;edgecheck 46/46;
panelanim 156/156;errcheck 19/19。
2026-10-16 04:56:35 +08:00

276 lines
17 KiB
PowerShell

# Structural verification (NOT part of the shipped app; under .preflight/, gitignored).
#
# Proves on disk -- not by prose -- that the two structural requirements are satisfied:
# [1] The giant implementation files were split by responsibility into sibling files
# under each component's own Src/ (and each new translation unit is compiled).
# [2] The common UI controls live in a real `Components/Shared` component, are compiled
# into the binary, and are consumed by the other components.
#
# Run: powershell -File .preflight\perf\structcheck.ps1
# ASCII only (PowerShell 5.1 reads .ps1 as ANSI).
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
$pass = 0
$fail = 0
function Check([bool]$ok, [string]$what) {
if ($ok) { $script:pass++ ; Write-Host (" OK " + $what) }
else { $script:fail++ ; Write-Host (" FAIL " + $what) }
}
function Lines([string]$path) {
if (-not (Test-Path $path)) { return -1 }
return (Get-Content $path | Measure-Object -Line).Lines
}
Write-Host "== [1] split of the giant implementation files =="
# 1a. every expected file of the split exists and is a sibling under the component's Src/
$split = @(
"Components/PlotView/Src/PlotView.cpp",
"Components/PlotView/Src/PlotViewPaint.cpp",
"Components/PlotView/Src/PlotViewInteraction.cpp",
"Components/PlotView/Src/PlotViewFeatures.cpp",
"Components/PlotView/Src/PlotViewInternal.hpp",
"Components/SignalPanel/Src/SignalPanel.cpp",
"Components/SignalPanel/Src/SignalPanelPaint.cpp",
"Components/SignalPanel/Src/SignalPanelInteraction.cpp",
"Components/SignalPanel/Src/SignalPanelInternal.hpp",
"Components/Application/Src/Application.cpp",
"Components/Application/Src/ApplicationCommands.cpp",
"Components/Application/Src/ApplicationLoading.cpp",
"Components/Application/Src/ApplicationInternal.hpp",
"Utilities/Src/DataModel.cpp",
"Utilities/Src/DataModelTab.cpp",
"Utilities/Src/DataModelInternal.hpp"
)
foreach ($f in $split) {
$n = Lines $f
Check ($n -gt 0) ("exists + non-empty: " + $f + " (" + $n + " lines)")
}
# 1b. the core file of each split is a small fraction of the original monster file
Check ((Lines "Components/PlotView/Src/PlotView.cpp") -lt 900) "PlotView.cpp shrunk to < 900 lines (was 13 695)"
Check ((Lines "Components/PlotView/Src/PlotViewPaint.cpp") -gt 4000) "PlotViewPaint.cpp carries the rendering (> 4000 lines)"
Check ((Lines "Components/SignalPanel/Src/SignalPanel.cpp") -lt 1200) "SignalPanel.cpp shrunk to < 1200 lines (was 4 184)"
Check ((Lines "Components/Application/Src/Application.cpp") -lt 1500) "Application.cpp shrunk to < 1500 lines (was 3 135)"
Check ((Lines "Utilities/Src/DataModel.cpp") -lt 1600) "DataModel.cpp shrunk to < 1600 lines (was 2 615)"
# 1c. responsibility really moved: each concern is defined in exactly one file
$concerns = @(
@{ symbol = "PlotView::Paint"; file = "PlotViewPaint.cpp" },
@{ symbol = "PlotView::PaintLane"; file = "PlotViewPaint.cpp" },
@{ symbol = "PlotView::PaintHint"; file = "PlotViewPaint.cpp" },
@{ symbol = "PlotView::LaneAtY"; file = "PlotViewInteraction.cpp" },
@{ symbol = "PlotView::CancelDrag"; file = "PlotViewInteraction.cpp" },
@{ symbol = "PlotView::FitAll"; file = "PlotViewFeatures.cpp" },
@{ symbol = "PlotView::SetHint"; file = "PlotViewFeatures.cpp" },
@{ symbol = "PlotView::Layout"; file = "PlotView.cpp" },
@{ symbol = "PlotView::BuildLaneVisuals"; file = "PlotView.cpp" },
@{ symbol = "SignalPanel::Paint"; file = "SignalPanelPaint.cpp" },
@{ symbol = "SignalPanel::OnLButtonDown"; file = "SignalPanelInteraction.cpp" },
@{ symbol = "SignalPanel::EnsureRows"; file = "SignalPanel.cpp" }
)
foreach ($c in $concerns) {
$hits = @(Select-String -Path "Components/*/Src/*.cpp", "Utilities/Src/*.cpp" -Pattern ("^\s*\w[\w:<>, ]* " + [regex]::Escape($c.symbol) + "\(") |
ForEach-Object { Split-Path $_.Path -Leaf })
$ok = ($hits.Count -eq 1) -and ($hits[0] -eq $c.file)
Check $ok ($c.symbol + " defined only in " + $c.file + " (found: " + ($hits -join ",") + ")")
}
# 1d. the constant/tool block of each big file became a named internal header + using-declaration
$internal = @(
@{ file = "Components/PlotView/Src/PlotViewInternal.hpp"; ns = "pvdetail" },
@{ file = "Components/SignalPanel/Src/SignalPanelInternal.hpp"; ns = "spdetail" },
@{ file = "Components/Application/Src/ApplicationInternal.hpp"; ns = "appdetail" },
@{ file = "Utilities/Src/DataModelInternal.hpp"; ns = "dmdetail" }
)
foreach ($i in $internal) {
$text = Get-Content $i.file -Raw
$hasNs = $text -match ("namespace\s+" + $i.ns)
$hasUsing = $text -match ("using namespace\s+" + $i.ns + "\s*;")
$inlineCount = ([regex]::Matches($text, "(?m)^\s*(?:\[\[[^\]]*\]\]\s*)?inline\s")).Count
Check $hasNs ($i.file + " declares namespace " + $i.ns)
Check $hasUsing ($i.file + " injects it via the using-declaration for " + $i.ns)
Check ($inlineCount -ge 4) ($i.file + " marks the shared helpers inline (" + $inlineCount + " definitions)")
}
# 1e. the split files are compiled, and their objects hold the moved code
$objs = @("PlotView.o","PlotViewPaint.o","PlotViewInteraction.o","PlotViewFeatures.o",
"SignalPanel.o","SignalPanelPaint.o","SignalPanelInteraction.o",
"Application.o","ApplicationCommands.o","ApplicationLoading.o",
"DataModel.o","DataModelTab.o")
$missing = @()
foreach ($o in $objs) { if (-not (Test-Path ("Build/Release/" + $o))) { $missing += $o } }
Check ($missing.Count -eq 0) ("all split translation units were compiled (missing: " + ($missing -join ",") + ")")
$nm = Get-Command nm -ErrorAction SilentlyContinue
if ($nm -ne $null) {
$paintInPaint = @(nm --defined-only -C "Build/Release/PlotViewPaint.o" 2>$null | Select-String "jdi::PlotView::Paint")
$paintInCore = @(nm --defined-only -C "Build/Release/PlotView.o" 2>$null | Select-String "jdi::PlotView::Paint")
Check ($paintInPaint.Count -gt 0) "nm: paint symbols live in PlotViewPaint.o"
Check ($paintInCore.Count -eq 0) "nm: no paint symbol left in PlotView.o"
} else {
Check $false "nm not available (cannot verify symbol placement)"
}
# 1f. every implementation file of every component is registered in the Makefile
$srcFiles = @(Get-ChildItem "Components/*/Src/*.cpp", "Utilities/Src/*.cpp" | ForEach-Object {
$_.FullName.Substring($root.Length + 1).Replace("\", "/")
})
$makefile = Get-Content "Makefile" -Raw
$unregistered = @()
foreach ($s in $srcFiles) {
$dir = $s.Substring(0, $s.LastIndexOf("/"))
if ($makefile -notmatch [regex]::Escape($dir)) { $unregistered += $s }
}
Check ($unregistered.Count -eq 0) ("every implementation directory is registered in the Makefile (missing: " + ($unregistered -join ",") + ")")
Check ($srcFiles.Count -ge 24) ("implementation files in the tree: " + $srcFiles.Count)
Write-Host ""
Write-Host "== [2] Components/Shared =="
Check (Test-Path "Components/Shared/Inc/UiControls.hpp") "Components/Shared/Inc/UiControls.hpp exists"
Check (Test-Path "Components/Shared/Src/UiControls.cpp") "Components/Shared/Src/UiControls.cpp exists"
Check ((Lines "Components/Shared/Inc/UiControls.hpp") -gt 100) ("UiControls.hpp declares the control API (" + (Lines "Components/Shared/Inc/UiControls.hpp") + " lines)")
# 2a. the shared header really owns the enumerated control families
$families = @("UiDrawPlusIcon","UiDrawCrossIcon","UiDrawTriangleIcon","UiDrawCheckBox",
"UiDrawSlider","UiDrawEditField","UiEditFieldMouseDown","UiDrawSwatch",
"UiSwatchNeedsOutline","UiContrastInk","UiSliderRatioFromX")
$header = Get-Content "Components/Shared/Inc/UiControls.hpp" -Raw
$missingFamilies = @()
foreach ($f in $families) { if ($header -notmatch ("\b" + $f + "\b")) { $missingFamilies += $f } }
Check ($missingFamilies.Count -eq 0) ("shared header exposes all control families (missing: " + ($missingFamilies -join ",") + ")")
# 2b. symbol-level proof: every declared control is DEFINED by UiControls.o and by no other object.
# (Text matching cannot tell a definition from a call, so ask the linker's view instead.)
$declared = @([regex]::Matches($header, "(?m)^\s*[\w:<>, ]+\s+(Ui\w+)\s*\(") | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
Check ($declared.Count -ge 15) ("UiControls.hpp declares " + $declared.Count + " control functions")
$definitionOwner = @{}
$objectFiles = @(Get-ChildItem "Build/Release/*.o" -ErrorAction SilentlyContinue)
if ($nm -eq $null) {
Check $false "nm not available (cannot verify control definitions)"
} else {
foreach ($o in $objectFiles) {
$syms = nm --defined-only -C $o.FullName 2>$null
foreach ($name in $declared) {
if ($syms | Select-String (" T jdi::" + [regex]::Escape($name) + "\(")) {
$definitionOwner[$name] = $o.Name
}
}
}
$wrongOwner = @($declared | Where-Object { -not $definitionOwner.ContainsKey($_) -or $definitionOwner[$_] -ne "UiControls.o" })
Check ($wrongOwner.Count -eq 0) ("nm: all " + $declared.Count + " declared controls are defined in UiControls.o (wrong/undefined: " + ($wrongOwner -join ",") + ")")
$uiControlsSymbols = @(nm --defined-only -C "Build/Release/UiControls.o" 2>$null | Select-String " T jdi::Ui").Count
Check ($uiControlsSymbols -ge 15) ("nm: UiControls.o defines " + $uiControlsSymbols + " Ui* symbols")
}
# 2c. other components actually consume them (>= 3 components, >= 30 call sites)
$consumers = @(Select-String -Path "Components/*/Src/*.cpp", "Components/*/Inc/*.hpp" -Pattern "UiDrawPlusIcon|UiDrawCrossIcon|UiDrawTriangleIcon|UiDrawCheckBox|UiDrawSlider|UiDrawEditField|UiEditFieldMouseDown|UiEditFieldMouseMove|UiDrawSwatch|UiSwatchNeedsOutline|UiContrastInk|UiSliderRatioFromX|UiEditTextLeft|UiEditOffsetAt" |
Where-Object { (Split-Path $_.Path -Leaf) -ne "UiControls.hpp" } |
Group-Object { ($_.Path.Substring($root.Length + 1) -split "[\\/]")[1] })
$componentNames = @($consumers | ForEach-Object { $_.Name })
$totalCalls = 0
foreach ($g in $consumers) { $totalCalls += $g.Count }
Check ($componentNames.Count -ge 3) ("shared controls consumed by >= 3 components (" + ($componentNames -join ",") + ")")
Check ($totalCalls -ge 30) ("shared control call sites across components: " + $totalCalls)
# 2d. layering, stated exactly as the code obeys it:
# Components/Shared -> Utilities only (it must not include any component header)
# Utilities/PaintUtil = base layer (must not include UiControls.hpp)
# Utilities must not include feature components (PlotView / SignalPanel / Toolbar / TabBar)
# NOTE: Utilities/Src/PopupMenu.cpp and Utilities/Src/ColorPickerPopup.cpp are hand-drawn UI
# widgets that live in the Utilities folder historically; they consume the shared controls
# (same direction as the components do) and are deliberately excluded from the ban below.
$sharedIncludes = @(Select-String -Path "Components/Shared/Inc/*.hpp", "Components/Shared/Src/*.cpp" -Pattern '^#include "' |
Where-Object { $_.Line -match '"(Application|PlotView|SignalPanel|Toolbar|TabBar|PopupMenu|ColorPickerPopup)\.hpp"' })
Check ($sharedIncludes.Count -eq 0) "Components/Shared depends on Utilities only (no component header)"
$paintUtilIncludes = @(Select-String -Path "Utilities/Inc/PaintUtil.hpp", "Utilities/Src/PaintUtil.cpp" -Pattern '#include "UiControls.hpp"' -ErrorAction SilentlyContinue)
Check ($paintUtilIncludes.Count -eq 0) "Utilities/PaintUtil (base layer) does not depend on Components/Shared"
$badDir = @(Select-String -Path "Utilities/Src/*.cpp", "Utilities/Inc/*.hpp" -Pattern '#include "(PlotView|SignalPanel|Toolbar|TabBar)' -ErrorAction SilentlyContinue)
Check ($badDir.Count -eq 0) "Utilities never includes a feature component"
Write-Host ""
Write-Host "== [3] user-requested behaviour invariants (source level) =="
$utf8 = New-Object System.Text.UTF8Encoding($false)
$panelFile = "Components/SignalPanel/Src/SignalPanel.cpp"
$panelIntFile = "Components/SignalPanel/Src/SignalPanelInteraction.cpp"
$modelFile = "Utilities/Inc/DataModel.hpp"
$panelText = [System.IO.File]::ReadAllText($panelFile, $utf8)
$panelIntText = [System.IO.File]::ReadAllText($panelIntFile, $utf8)
$modelText = [System.IO.File]::ReadAllText($modelFile, $utf8)
# 3a. collapsing a group needs the disclosure triangle OR the "Group N" label -- clicking the name
# itself must not collapse it. (User 2026-10-16.)
Check ($panelText -match "return RowPart::GROUP_TITLE;") "hit test yields a dedicated GROUP_TITLE part"
Check ($panelText -match "const gd::RectF labelRect\(labelLeft, rect\.Y, GroupLabelWidth\(row\), rect\.Height\);") "GROUP_TITLE hot zone is exactly the Group-N label rect"
$gate = @(Select-String -Path $panelIntFile -Pattern "^\s*if \(part != RowPart::DISCLOSURE && part != RowPart::GROUP_TITLE\)")
Check ($gate.Count -eq 1) ("single-click toggle gate accepts only DISCLOSURE / GROUP_TITLE (found " + $gate.Count + ")")
$toggles = @(Select-String -Path $panelIntFile -Pattern "^\s*ToggleGroupFromRow\(item\.groupIndex, row\);")
Check ($toggles.Count -eq 2) ("ToggleGroupFromRow has exactly 2 entry points, single click + double click (found " + $toggles.Count + ")")
Check ($panelIntText -match "(?s)if \(part == RowPart::DISCLOSURE\)\s*\{\s*//[^\n]*\n(\s*//[^\n]*\n)*\s*ToggleGroupFromRow") "double click on the triangle still collapses"
Check ($panelIntText -match "(?s)if \(part != RowPart::BODY\)\s*\{\s*return false;\s*\}") "double click outside the name body never starts a rename"
# 3b. the group rename box must stop before the "N items" counter. (User 2026-10-16.)
Check ($panelText -match "right = statsRight - statsWidth - 6\.0f \* m_scale;") "rename box right edge excludes the item counter"
Check ($panelText -notmatch "right = statsRight;") "rename box never extends over the counter"
Check ($panelText -match "const float statsWidth = UiMeasureTextWidth\(m_fonts->uiSmall, GroupStatsText\(row\)\);") "counter width comes from GroupStatsText"
# 3c. SignalData / SignalGroup keep exactly ONE name field. (User 2026-10-16: drop displayName.)
$data = [regex]::Match($modelText, "(?s)struct SignalData\s*\{(.*?)\n\t\};")
Check ($data.Success) "SignalData struct found in the data model"
if ($data.Success) {
$body = $data.Groups[1].Value
$fields = @([regex]::Matches($body, "std::wstring\s+\w+;"))
Check ($fields.Count -eq 1) ("SignalData declares exactly one string field (found " + $fields.Count + ")")
Check ($body -match "std::wstring name;") "that single field is name"
}
$group = [regex]::Match($modelText, "(?s)struct SignalGroup\s*\{(.*?)\n\t\};")
Check ($group.Success) "SignalGroup struct found in the data model"
if ($group.Success) {
$gbody = $group.Groups[1].Value
$gfields = @([regex]::Matches($gbody, "std::wstring\s+\w+;"))
Check ($gfields.Count -eq 1) ("SignalGroup declares exactly one string field (found " + $gfields.Count + ")")
}
Check ($modelText -match "bool RenameSignal\(") "DataSet::RenameSignal replaces the old SetSignalDisplayName"
Check ($modelText -match "bool RenameGroup\(") "DataSet::RenameGroup replaces the old SetGroupDisplayName"
$staleNames = @(Select-String -Path "Components/*/Src/*.cpp","Components/*/Inc/*.hpp","Components/*/Src/*.hpp",
"Utilities/Src/*.cpp","Utilities/Inc/*.hpp" -Pattern "displayName|SetSignalDisplayName|SetGroupDisplayName|\.Title\(\)|->Title\(\)")
Check ($staleNames.Count -eq 0) ("no displayName / Title() left in any source (found " + $staleNames.Count + ")")
# 3d. comment hygiene, driven by a UTF-8 data file (the .ps1 itself must stay ASCII for PowerShell 5.1)
$phraseFile = "Tools/structcheck_phrases.txt"
Check (Test-Path $phraseFile) "comment-hygiene phrase list exists"
if (Test-Path $phraseFile) {
$scanFiles = @(Get-ChildItem "Components/*/Src/*.cpp","Components/*/Src/*.hpp","Components/*/Inc/*.hpp",
"Utilities/Src/*.cpp","Utilities/Inc/*.hpp" | ForEach-Object { $_.FullName })
$scanText = @{}
foreach ($f in $scanFiles) { $scanText[$f] = [System.IO.File]::ReadAllText($f, $utf8) }
foreach ($line in [System.IO.File]::ReadAllLines($phraseFile, $utf8)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$parts = $line -split "`t"
$mode = $parts[0]
if ($mode -eq "forbid") {
$phrase = $parts[1]
$offenders = @($scanFiles | Where-Object { $scanText[$_].Contains($phrase) })
Check ($offenders.Count -eq 0) ("stale wording absent everywhere: " + $phrase + " (found in " + $offenders.Count + " files)")
} elseif ($mode -eq "require") {
$target = $parts[1]
$phrase = $parts[2]
Check ((Test-Path $target) -and ([System.IO.File]::ReadAllText($target, $utf8).Contains($phrase))) ("corrected comment present in " + $target + ": " + $phrase)
}
}
}
Write-Host ""
Write-Host ("structcheck: " + $pass + " passed, " + $fail + " failed")
if ($fail -ne 0) { exit 1 }
exit 0