@zalom/plastic 2.0.0-alpha.9 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +13 -136
- package/README.md +357 -133
- package/agents/plastic-enforcer.md +20 -15
- package/agents/plastic-executor.md +15 -4
- package/agents/plastic-node-research.md +30 -0
- package/agents/plastic-node-verify.md +28 -0
- package/agents/plastic-node-work.md +33 -0
- package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
- package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
- package/assets/plastic-logo.svg +1 -0
- package/bin/crap +4 -0
- package/bin/lib/context_budget.rb +43 -6
- package/bin/lib/skill_census.rb +839 -0
- package/bin/plastic +6 -0
- package/bin/plastic-skill-census +114 -0
- package/bin/test +24 -4
- package/bin/verify-change +345 -0
- package/config_asks.yml +4 -4
- package/deprecations.yml +1 -1
- package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
- package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
- package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
- package/docs/help/human-report-contract.md +152 -0
- package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
- package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
- package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
- package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
- package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
- package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
- package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +55 -2
- package/hooks/session-start +5 -1
- package/hooks/statusline +32 -27
- package/hooks/stop +5 -0
- package/package.json +5 -3
- package/scripts/append-ledger +2 -1
- package/scripts/dashboard.rb +267 -16
- package/scripts/day-summary +2 -1
- package/scripts/doctor.rb +680 -39
- package/scripts/end-intent +153 -30
- package/scripts/exec-worktree +5 -5
- package/scripts/file-session-intent +2 -1
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +24 -123
- package/scripts/hook-close +2 -1
- package/scripts/hook-message-display +7 -0
- package/scripts/hook-record +24 -16
- package/scripts/hook-savepoint +27 -3
- package/scripts/hook-session-start +359 -321
- package/scripts/hook-stop +58 -0
- package/scripts/index-projection +74 -0
- package/scripts/insight-append +17 -4
- package/scripts/install.rb +9 -7
- package/scripts/lib/action_graph_shim.rb +279 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +63 -25
- package/scripts/lib/arm.rb +48 -83
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/backup.rb +65 -0
- package/scripts/lib/cli/command.rb +92 -0
- package/scripts/lib/cli/commands/auto.rb +18 -0
- package/scripts/lib/cli/commands/auto_brief.rb +44 -0
- package/scripts/lib/cli/commands/auto_lock.rb +99 -0
- package/scripts/lib/cli/commands/auto_report.rb +50 -0
- package/scripts/lib/cli/commands/auto_take.rb +31 -0
- package/scripts/lib/cli/commands/backup.rb +43 -0
- package/scripts/lib/cli/commands/checkout.rb +25 -0
- package/scripts/lib/cli/commands/continue.rb +66 -0
- package/scripts/lib/cli/commands/doctor.rb +81 -0
- package/scripts/lib/cli/commands/feedback.rb +40 -0
- package/scripts/lib/cli/commands/help.rb +69 -0
- package/scripts/lib/cli/commands/hook.rb +32 -0
- package/scripts/lib/cli/commands/index.rb +23 -0
- package/scripts/lib/cli/commands/install.rb +21 -0
- package/scripts/lib/cli/commands/installer_verb.rb +37 -0
- package/scripts/lib/cli/commands/intent.rb +19 -0
- package/scripts/lib/cli/commands/intent_answer.rb +37 -0
- package/scripts/lib/cli/commands/intent_command.rb +53 -0
- package/scripts/lib/cli/commands/intent_end.rb +62 -0
- package/scripts/lib/cli/commands/intent_new.rb +62 -0
- package/scripts/lib/cli/commands/intent_note.rb +43 -0
- package/scripts/lib/cli/commands/intent_rule.rb +36 -0
- package/scripts/lib/cli/commands/intent_show.rb +32 -0
- package/scripts/lib/cli/commands/intent_spec.rb +44 -0
- package/scripts/lib/cli/commands/intent_step.rb +71 -0
- package/scripts/lib/cli/commands/intent_verify.rb +26 -0
- package/scripts/lib/cli/commands/migrate.rb +16 -0
- package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
- package/scripts/lib/cli/commands/next.rb +54 -0
- package/scripts/lib/cli/commands/project.rb +19 -0
- package/scripts/lib/cli/commands/project_links.rb +42 -0
- package/scripts/lib/cli/commands/project_list.rb +20 -0
- package/scripts/lib/cli/commands/project_new.rb +72 -0
- package/scripts/lib/cli/commands/query.rb +31 -0
- package/scripts/lib/cli/commands/render.rb +33 -0
- package/scripts/lib/cli/commands/roadmap.rb +19 -0
- package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
- package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
- package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
- package/scripts/lib/cli/commands/rollback.rb +20 -0
- package/scripts/lib/cli/commands/search.rb +60 -0
- package/scripts/lib/cli/commands/session.rb +18 -0
- package/scripts/lib/cli/commands/session_commit.rb +42 -0
- package/scripts/lib/cli/commands/session_handoff.rb +34 -0
- package/scripts/lib/cli/commands/session_summary.rb +35 -0
- package/scripts/lib/cli/commands/status.rb +68 -0
- package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
- package/scripts/lib/cli/commands/sync.rb +46 -0
- package/scripts/lib/cli/commands/uninstall.rb +20 -0
- package/scripts/lib/cli/commands/update.rb +20 -0
- package/scripts/lib/cli/commands/version.rb +53 -0
- package/scripts/lib/cli/frontier.rb +86 -0
- package/scripts/lib/cli/intent_progress.rb +36 -0
- package/scripts/lib/cli/legacy.rb +80 -0
- package/scripts/lib/cli/output.rb +137 -0
- package/scripts/lib/cli/scope.rb +134 -0
- package/scripts/lib/cli/table.rb +65 -0
- package/scripts/lib/cli.rb +94 -0
- package/scripts/lib/codex_adapter.rb +198 -0
- package/scripts/lib/compact_instructions.rb +13 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/data_boundary.rb +132 -0
- package/scripts/lib/day_summary.rb +23 -14
- package/scripts/lib/doctor_core.rb +114 -38
- package/scripts/lib/doctor_session_ledger.rb +5 -53
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/feedback_report.rb +1 -1
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +413 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +27 -3
- package/scripts/lib/hook_replay.rb +229 -0
- package/scripts/lib/index_entry.rb +62 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +531 -71
- package/scripts/lib/intent_screen.rb +4 -4
- package/scripts/lib/intent_screen_ansi.rb +73 -12
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +10 -11
- package/scripts/lib/message_display.rb +350 -54
- package/scripts/lib/meter_watch.rb +185 -0
- package/scripts/lib/node_file.rb +234 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_input.rb +913 -0
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +386 -0
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/node_return.rb +204 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/preflight.rb +4 -6
- package/scripts/lib/project_config.rb +46 -0
- package/scripts/lib/project_validator.rb +3 -2
- package/scripts/lib/qmd_sync.rb +8 -7
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/reference_archive.rb +45 -0
- package/scripts/lib/release_guard.rb +18 -0
- package/scripts/lib/report_screen.rb +1371 -47
- package/scripts/lib/rlm/corpus.rb +13 -0
- package/scripts/lib/rlm/probe.rb +29 -0
- package/scripts/lib/rlm/query.rb +22 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +158 -8
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +64 -14
- package/scripts/lib/runner_absorb.rb +703 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +525 -0
- package/scripts/lib/runner_policy.rb +191 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +141 -19
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/screen_paint.rb +365 -28
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/search_index.rb +55 -0
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +25 -18
- package/scripts/lib/session_ledger.rb +48 -4
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/sqlite.rb +22 -0
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/store_discovery.rb +7 -6
- package/scripts/lib/store_layout.rb +54 -0
- package/scripts/lib/store_provisioning.rb +2 -1
- package/scripts/lib/store_sync.rb +85 -0
- package/scripts/lib/stores_move.rb +93 -0
- package/scripts/lib/verify_intent.rb +36 -8
- package/scripts/lib/version_number.rb +48 -0
- package/scripts/lib/work_graph.rb +59 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/lib/worktree.rb +27 -32
- package/scripts/lib/worktree_sweep.rb +6 -5
- package/scripts/link-suggest +2 -1
- package/scripts/meter-watch +57 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +4 -13
- package/scripts/node-input +92 -0
- package/scripts/node-run +225 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +42 -33
- package/scripts/promote-session-item +3 -2
- package/scripts/read-config +53 -9
- package/scripts/ready-set +126 -0
- package/scripts/release-check +123 -0
- package/scripts/report-screen +177 -16
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +581 -0
- package/scripts/savepoint-note +11 -9
- package/scripts/session-commit +2 -1
- package/scripts/session-usage +56 -0
- package/scripts/skill-lint +115 -6
- package/scripts/spawn-preamble +2 -2
- package/scripts/update.rb +25 -4
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +3 -2
- package/scripts/write-handoff +2 -1
- package/templates/agents.md +7 -7
- package/templates/config.yml +16 -9
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/index.md +1 -1
- package/templates/intent-screen.md +1 -1
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +13 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +8 -3
- package/templates/project.yml +1 -1
- package/templates/render.css +10 -0
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +1 -1
- package/templates/roadmap.md +13 -0
- package/bin/plastic.js +0 -70
- package/scripts/lib/bridge.rb +0 -116
- package/skills/agent-advisor/SKILL.md +0 -92
- package/skills/auto/SKILL.md +0 -296
- package/skills/auto/evals/evals.json +0 -255
- package/skills/auto/references/end-tail.md +0 -66
- package/skills/auto/references/human-report-contract.md +0 -78
- package/skills/conventions/SKILL.md +0 -29
- package/skills/dashboard/SKILL.md +0 -169
- package/skills/dashboard/evals/evals.json +0 -38
- package/skills/dashboard/references/classification.md +0 -22
- package/skills/dashboard/templates/dashboard-global.md +0 -20
- package/skills/dashboard/templates/dashboard-project.md +0 -19
- package/skills/direct/SKILL.md +0 -66
- package/skills/direct/references/request-signals.md +0 -59
- package/skills/doctor/SKILL.md +0 -299
- package/skills/doctor/report.md +0 -102
- package/skills/feedback/SKILL.md +0 -98
- package/skills/feedback/references/transport-and-privacy.md +0 -65
- package/skills/feedback/report.md +0 -36
- package/skills/install/SKILL.md +0 -217
- package/skills/intent-continuing/SKILL.md +0 -154
- package/skills/intent-continuing/references/board-fill.md +0 -43
- package/skills/intent-continuing/references/boarding-matrix.md +0 -34
- package/skills/intent-continuing/references/context-management.md +0 -28
- package/skills/intent-continuing/references/liveness-ranking.md +0 -57
- package/skills/intent-creating/SKILL.md +0 -164
- package/skills/intent-creating/evals/evals.json +0 -72
- package/skills/intent-creating/references/lifecycle.md +0 -81
- package/skills/intent-creating/references/wikilinks.md +0 -8
- package/skills/intent-ending/SKILL.md +0 -176
- package/skills/intent-ending/evals/evals.json +0 -74
- package/skills/intent-executing/SKILL.md +0 -170
- package/skills/intent-executing/evals/evals.json +0 -66
- package/skills/intent-executing/implementer-prompt.md +0 -42
- package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
- package/skills/intent-speccing/SKILL.md +0 -130
- package/skills/intent-speccing/evals/evals.json +0 -126
- package/skills/intent-speccing/references/design-principles.md +0 -44
- package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
- package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
- package/skills/project-creating/SKILL.md +0 -162
- package/skills/project-creating/references/hubs-projects.md +0 -55
- package/skills/project-creating/references/project-scaffolding.md +0 -97
- package/skills/releasing/SKILL.md +0 -337
- package/skills/releasing/references/deprecations.md +0 -60
- package/skills/releasing/references/promotion-and-tagging.md +0 -66
- package/skills/releasing/references/release-lines.md +0 -105
- package/skills/roadmap/SKILL.md +0 -64
- package/skills/roadmap/references/file-format.md +0 -124
- package/skills/roadmap/references/operations.md +0 -112
- package/skills/rollback/SKILL.md +0 -91
- package/skills/tutorial/SKILL.md +0 -65
- package/skills/tutorial/evals/evals.json +0 -186
- package/skills/uninstall/SKILL.md +0 -75
- package/skills/update/SKILL.md +0 -126
- /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
- /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
- /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
- /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require_relative "search_index"
|
|
6
|
+
require_relative "reference_archive"
|
|
7
|
+
require_relative "work_graph"
|
|
8
|
+
|
|
9
|
+
module Plastic
|
|
10
|
+
module StoreSync
|
|
11
|
+
INTENT_PATH = %r{\A(?:(?:projects|stores)/([^/]+)/)?store/([^/]+?)--}
|
|
12
|
+
ROW_HASH = "lower(hex(sha3(sqlar_uncompress(data, sz), 256)))"
|
|
13
|
+
|
|
14
|
+
def self.action(state)
|
|
15
|
+
file, row, recorded = state.values_at("file", "row", "hash")
|
|
16
|
+
return "write" if file.nil?
|
|
17
|
+
return if file == row
|
|
18
|
+
return "read" if row == recorded
|
|
19
|
+
return "write" if file == recorded
|
|
20
|
+
|
|
21
|
+
"conflict"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.plan(home)
|
|
25
|
+
known = states(home)
|
|
26
|
+
fresh = SearchIndex.files(home) - known.map { |state| state["path"] }
|
|
27
|
+
plan = known.map { |state| [action(state), state["path"]] } + fresh.map { |file| ["read", file] }
|
|
28
|
+
plan.select(&:first).sort_by(&:last)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.states(home)
|
|
32
|
+
Sqlite.call(SearchIndex.path(home), <<~SQL, readonly: true)
|
|
33
|
+
SELECT path, lower(hex(sha3(readfile(#{Sqlite.quote(home)} || '/' || path), 256))) AS file, #{ROW_HASH} AS row, hash
|
|
34
|
+
FROM doc;
|
|
35
|
+
SQL
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.apply(home, plan)
|
|
39
|
+
statements = plan.map { |action, path| send(action, home, path) }
|
|
40
|
+
statements << "INSERT INTO ft(ft) VALUES('rebuild');" if plan.any? { |action, _path| action == "read" }
|
|
41
|
+
Sqlite.call(SearchIndex.path(home), ["BEGIN;", *statements, "COMMIT;"].join("\n"))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.read(home, path)
|
|
45
|
+
"DELETE FROM doc WHERE path = #{Sqlite.quote(path)};\n#{SearchIndex.insert(home, path)}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.write(home, path)
|
|
49
|
+
where = "WHERE path = #{Sqlite.quote(path)}"
|
|
50
|
+
"#{restore(home, "doc", "path")} #{where};\nUPDATE doc SET hash = #{ROW_HASH} #{where};"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.restore(home, table, column)
|
|
54
|
+
"SELECT writefile(#{Sqlite.quote(home)} || '/' || #{column}, sqlar_uncompress(data, sz)) FROM #{table}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.diff(home, path)
|
|
58
|
+
body = Sqlite.call(SearchIndex.path(home), <<~SQL, readonly: true).first["body"]
|
|
59
|
+
SELECT CAST(sqlar_uncompress(data, sz) AS TEXT) AS body FROM doc WHERE path = #{Sqlite.quote(path)};
|
|
60
|
+
SQL
|
|
61
|
+
Tempfile.create("plastic-row") do |row|
|
|
62
|
+
row.write(body)
|
|
63
|
+
row.flush
|
|
64
|
+
Open3.capture2("diff", "-u", "-L", "row", "-L", "file", row.path, File.join(home, path)).first
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.orphans(home)
|
|
69
|
+
paths = Sqlite.call(SearchIndex.path(home), "SELECT path FROM doc;", readonly: true) +
|
|
70
|
+
Sqlite.call(ReferenceArchive.path(home), "SELECT name AS path FROM sqlar;", readonly: true)
|
|
71
|
+
keys = paths.filter_map { |row| row["path"].match(INTENT_PATH) }.map { |found| "#{found[1] || "global"}:#{found[2]}" }
|
|
72
|
+
(keys.uniq - WorkGraph.known(home)).sort
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.checkout(home)
|
|
76
|
+
{SearchIndex.path(home) => %w[doc path], ReferenceArchive.path(home) => %w[sqlar name]}.flat_map do |database, (table, column)|
|
|
77
|
+
missing = Sqlite.call(database, "SELECT #{column} AS path FROM #{table};", readonly: true)
|
|
78
|
+
.map { |row| row["path"] }.reject { |path| File.exist?(File.join(home, path)) }
|
|
79
|
+
names = missing.map { |path| Sqlite.quote(path) }.join(", ")
|
|
80
|
+
Sqlite.call(database, "#{restore(home, table, column)} WHERE #{column} IN (#{names});", readonly: true)
|
|
81
|
+
missing
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "store_layout"
|
|
5
|
+
require_relative "sqlite"
|
|
6
|
+
require_relative "lock"
|
|
7
|
+
|
|
8
|
+
module Plastic
|
|
9
|
+
class StoresMove
|
|
10
|
+
Refused = Class.new(StandardError)
|
|
11
|
+
|
|
12
|
+
GLOBAL_ENTRIES = %w[store INDEX.md roadmaps].freeze
|
|
13
|
+
PATH_COLUMNS = {"knowledge_graph.db" => %w[doc path], "references.db" => %w[sqlar name]}.freeze
|
|
14
|
+
LOCKS = "{store,projects/*/store}/*/delivery.lock"
|
|
15
|
+
|
|
16
|
+
def initialize(home, qmd_index:, dry_run: false)
|
|
17
|
+
@home = home
|
|
18
|
+
@qmd_index = qmd_index
|
|
19
|
+
@dry_run = dry_run
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
refuse
|
|
24
|
+
plan = [["copy", copy]] + moves.map { |from, to| ["move", "#{from} -> #{to}"] } + rewrites.map { |file| ["rewrite", file] }
|
|
25
|
+
run unless @dry_run
|
|
26
|
+
plan
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def copy
|
|
30
|
+
"#{@home}-before-stores-move"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def refuse
|
|
36
|
+
raise Refused, "#{File.join(@home, "stores")}/ exists, so this home has moved already" if StoreLayout.moved?(@home)
|
|
37
|
+
raise Refused, "a delivery lock is held: #{held.join(", ")}" unless held.empty?
|
|
38
|
+
raise Refused, "#{copy} exists; remove it or move it away first" if File.exist?(copy)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def held
|
|
42
|
+
Dir.glob(LOCKS, base: @home).select { |lock| Time.now - File.mtime(File.join(@home, lock)) < Lock::TTL_SECONDS }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def moves
|
|
46
|
+
@moves ||= GLOBAL_ENTRIES.select { |name| File.exist?(File.join(@home, name)) }.map { |name| [name, "stores/global/#{name}"] } +
|
|
47
|
+
project_entries.map { |entry| ["projects/#{entry}", "stores/#{entry}"] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def project_entries
|
|
51
|
+
Dir.exist?(projects) ? Dir.children(projects).sort : []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def projects
|
|
55
|
+
File.join(@home, "projects")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def rewrites
|
|
59
|
+
[File.join(@home, "config.yml"), @qmd_index, *PATH_COLUMNS.keys.map { |name| File.join(@home, name) }].select { |file| File.file?(file) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run
|
|
63
|
+
files = rewrites
|
|
64
|
+
FileUtils.cp_r(@home, copy, preserve: true)
|
|
65
|
+
moves.each { |from, to| move(from, to) }
|
|
66
|
+
FileUtils.rmdir(projects) if Dir.exist?(projects)
|
|
67
|
+
files.each { |file| PATH_COLUMNS.key?(File.basename(file)) ? rewrite_rows(file) : rewrite_text(file) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def move(from, to)
|
|
71
|
+
FileUtils.mkdir_p(File.dirname(File.join(@home, to)))
|
|
72
|
+
FileUtils.mv(File.join(@home, from), File.join(@home, to))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def rewrite_text(file)
|
|
76
|
+
FileUtils.cp(file, "#{file}.before-stores-move", preserve: true)
|
|
77
|
+
File.write(file, [@home, "~/.plastic"].inject(File.read(file)) { |text, base| relocate(text, base) })
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def relocate(text, base)
|
|
81
|
+
text.gsub(%r{#{Regexp.escape(base)}/(store|roadmaps|INDEX\.md)\b}) { "#{base}/stores/global/#{Regexp.last_match(1)}" }
|
|
82
|
+
.gsub(%r{#{Regexp.escape(base)}/projects\b}) { "#{base}/stores" }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def rewrite_rows(file)
|
|
86
|
+
table, column = PATH_COLUMNS.fetch(File.basename(file))
|
|
87
|
+
Sqlite.call(file, <<~SQL)
|
|
88
|
+
UPDATE #{table} SET #{column} = 'stores/' || substr(#{column}, 10) WHERE #{column} LIKE 'projects/%';
|
|
89
|
+
UPDATE #{table} SET #{column} = 'stores/global/' || #{column} WHERE #{column} LIKE 'store/%';
|
|
90
|
+
SQL
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# encoding: UTF-8
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "store_layout"
|
|
4
5
|
require_relative "worktree"
|
|
5
6
|
require_relative "scaffold_intent"
|
|
6
7
|
|
|
@@ -9,7 +10,7 @@ require_relative "scaffold_intent"
|
|
|
9
10
|
# (`Doctor#run_intent_check`), a net-new added-line em-dash diff guard (the first standing
|
|
10
11
|
# implementation of this check; the only prior automated em-dash check,
|
|
11
12
|
# `test/skill_command_lint_test.rb`, asserts against two FIXED file sets and cannot scan a
|
|
12
|
-
# diff), a diffstat, and an optional caller-supplied `--suite` command
|
|
13
|
+
# diff), a diffstat, and an optional caller-supplied `--suite` command merged into the same
|
|
13
14
|
# verdict.
|
|
14
15
|
#
|
|
15
16
|
# Repo resolution and base-branch detection are NOT re-implemented here: `resolve_repo_dir`,
|
|
@@ -70,6 +71,10 @@ module VerifyIntent
|
|
|
70
71
|
lines.concat(diffstat_lines)
|
|
71
72
|
checks[:diffstat] = diffstat_check
|
|
72
73
|
|
|
74
|
+
report_lines_out, report_check = run_report_lines_check(intent_dir: intent_dir)
|
|
75
|
+
lines.concat(report_lines_out)
|
|
76
|
+
checks[:report] = report_check
|
|
77
|
+
|
|
73
78
|
if Worktree.blank?(suite)
|
|
74
79
|
checks[:suite] = { status: "skipped" }
|
|
75
80
|
else
|
|
@@ -92,13 +97,7 @@ module VerifyIntent
|
|
|
92
97
|
# shapes are told apart by whether store's grandparent is literally named "projects" (the
|
|
93
98
|
# StoreDiscovery convention), never by hardcoding ".plastic" as a name.
|
|
94
99
|
def resolve_plastic_home_and_scope(store)
|
|
95
|
-
|
|
96
|
-
grandparent = File.dirname(parent)
|
|
97
|
-
if File.basename(grandparent) == "projects"
|
|
98
|
-
[File.dirname(grandparent), "project:#{File.basename(parent)}"]
|
|
99
|
-
else
|
|
100
|
-
[parent, "global"]
|
|
101
|
-
end
|
|
100
|
+
Plastic::StoreLayout.home_and_scope(store)
|
|
102
101
|
end
|
|
103
102
|
|
|
104
103
|
# --- check 1: doctor, scoped to the intent --------------------------------------
|
|
@@ -235,6 +234,35 @@ module VerifyIntent
|
|
|
235
234
|
end
|
|
236
235
|
end
|
|
237
236
|
|
|
237
|
+
# --- check: the Report savepoint lines (intent 331f, F17) ------------------------
|
|
238
|
+
|
|
239
|
+
REPORT_LINE_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/.freeze
|
|
240
|
+
|
|
241
|
+
# Every `Report`-kind savepoint line for this intent, oldest first: [timestamp, text].
|
|
242
|
+
# Never fails or gates anything (D3: the diffstat check already prints a summary block,
|
|
243
|
+
# this merges into the same verdict so verify-intent surfaces them too) - a delivery with
|
|
244
|
+
# no Report line is visible to `doctor`'s intent_reports_printed_check instead.
|
|
245
|
+
def report_lines(intent_dir)
|
|
246
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
247
|
+
return [] unless File.exist?(path)
|
|
248
|
+
|
|
249
|
+
File.readlines(path).filter_map do |line|
|
|
250
|
+
m = line.strip.match(REPORT_LINE_RE)
|
|
251
|
+
next nil unless m && m[2] == "Report"
|
|
252
|
+
[m[1], m[3]]
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def run_report_lines_check(intent_dir:)
|
|
257
|
+
entries = report_lines(intent_dir)
|
|
258
|
+
if entries.empty?
|
|
259
|
+
[["report lines: none recorded"], { status: "pass", lines: [] }]
|
|
260
|
+
else
|
|
261
|
+
lines = ["report lines:"] + entries.map { |ts, text| "#{ts} Report #{text}" }
|
|
262
|
+
[lines, { status: "pass", lines: entries }]
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
238
266
|
# --- check 4: the optional suite --------------------------------------------------
|
|
239
267
|
|
|
240
268
|
# The supplied command is very likely a ruby command, so it is spawned with RUBYOPT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# VersionNumber - dotted version comparison without RubyGems (intent 363).
|
|
5
|
+
#
|
|
6
|
+
# The launcher starts as `ruby --disable-gems`, which saves about 26 of the 40
|
|
7
|
+
# milliseconds a plain Ruby process costs, so no file a command loads may reach
|
|
8
|
+
# for Gem::Version. Parses the leading run of dotted integers, ignoring a
|
|
9
|
+
# leading "v" and anything after the digits, and compares two of them segment by
|
|
10
|
+
# segment with the shorter one zero-padded. A string with no leading integer
|
|
11
|
+
# parses to nil, so a caller answers "not found" instead of raising.
|
|
12
|
+
#
|
|
13
|
+
# It deliberately does not order pre-release suffixes: 2.0.0-alpha.27 and 2.0.0
|
|
14
|
+
# compare equal here. A caller that needs RubyGems' pre-release order, such as
|
|
15
|
+
# the release-tag reader in scripts/report-screen, keeps Gem::Version.
|
|
16
|
+
class VersionNumber
|
|
17
|
+
include Comparable
|
|
18
|
+
|
|
19
|
+
LEADING_DIGITS = /\A(\d+(?:\.\d+)*)/
|
|
20
|
+
|
|
21
|
+
attr_reader :segments
|
|
22
|
+
|
|
23
|
+
def self.parse(value)
|
|
24
|
+
match = LEADING_DIGITS.match(value.to_s.strip.sub(/\Av/, ""))
|
|
25
|
+
match && new(match[1].split(".").map(&:to_i))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(segments)
|
|
29
|
+
@segments = segments
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def <=>(other)
|
|
33
|
+
return nil unless other.is_a?(VersionNumber)
|
|
34
|
+
|
|
35
|
+
width = [segments.length, other.segments.length].max
|
|
36
|
+
padded(width) <=> other.padded(width)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_s
|
|
40
|
+
segments.join(".")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
def padded(width)
|
|
46
|
+
segments + Array.new(width - segments.length, 0)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "sqlite"
|
|
5
|
+
require_relative "index_entry"
|
|
6
|
+
require_relative "store_discovery"
|
|
7
|
+
|
|
8
|
+
module Plastic
|
|
9
|
+
module WorkGraph
|
|
10
|
+
NAME = "work_graph.db"
|
|
11
|
+
SCHEMA = <<~SQL
|
|
12
|
+
CREATE TABLE intent(id TEXT, store TEXT, slug TEXT, status TEXT, title TEXT);
|
|
13
|
+
CREATE TABLE ledger(intent_id TEXT, store TEXT, at TEXT, stage TEXT, text TEXT);
|
|
14
|
+
SQL
|
|
15
|
+
STATUSES = %w[active future completed abandoned].freeze
|
|
16
|
+
LEDGER_LINE = /\A(\S+)\s{2,}(\S+)\s{2,}(.+)\z/
|
|
17
|
+
|
|
18
|
+
def self.path(home)
|
|
19
|
+
File.join(home, NAME)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.build(home)
|
|
23
|
+
draft = "#{path(home)}.tmp"
|
|
24
|
+
FileUtils.rm_f(draft)
|
|
25
|
+
rows = StoreDiscovery.discover(home)[:stores].flat_map { |store| intents(store) + ledger(store) }
|
|
26
|
+
Sqlite.call(draft, [SCHEMA, "BEGIN;", *rows, "COMMIT;"].join("\n"))
|
|
27
|
+
File.rename(draft, path(home))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.intents(store)
|
|
31
|
+
return [] unless File.exist?(store[:index])
|
|
32
|
+
|
|
33
|
+
status = nil
|
|
34
|
+
File.readlines(store[:index], chomp: true, encoding: "UTF-8").filter_map do |line|
|
|
35
|
+
status = line.delete_prefix("## ").downcase if line.start_with?("## ")
|
|
36
|
+
entry = STATUSES.include?(status) && IndexEntry.match(line)
|
|
37
|
+
entry && insert("intent", entry[1], store[:slug], File.basename(File.dirname(entry[3])).split("--", 2).last, status, entry[2])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.ledger(store)
|
|
42
|
+
Dir.glob("*/savepoint.md", base: store[:store]).sort.flat_map do |file|
|
|
43
|
+
id = file.split("--").first
|
|
44
|
+
File.readlines(File.join(store[:store], file), chomp: true, encoding: "UTF-8").filter_map do |line|
|
|
45
|
+
entry = line.match(LEDGER_LINE)
|
|
46
|
+
entry && insert("ledger", id, store[:slug], entry[1], entry[2], entry[3])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.insert(table, *values)
|
|
52
|
+
"INSERT INTO #{table} VALUES(#{values.map { |value| Sqlite.quote(value.to_s) }.join(", ")});"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.known(home)
|
|
56
|
+
Sqlite.call(path(home), "SELECT store, id FROM intent;", readonly: true).map { |row| "#{row["store"]}:#{row["id"]}" }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_file"
|
|
5
|
+
require_relative "graph_edges"
|
|
6
|
+
require_relative "node_file"
|
|
7
|
+
require_relative "action_graph_shim"
|
|
8
|
+
|
|
9
|
+
# WorkGraphValidator (intent 334, n4): the in-batch reader over one intent's
|
|
10
|
+
# graph.md and nodes/ - 327's rule for budget, files, and the decision and
|
|
11
|
+
# research kinds (review A17). {ok:, missing:, errors:}, the same Result shape
|
|
12
|
+
# ProjectValidator returns (review B7). Named apart from
|
|
13
|
+
# IntentValidator#validate_graph, which already exists for the KNOWLEDGE
|
|
14
|
+
# graph and stays unrelated to this one under D40 (review B9).
|
|
15
|
+
#
|
|
16
|
+
# Built over GraphFile, GraphEdges, and NodeFile only - never IntentValidator,
|
|
17
|
+
# never ReportScreen. Every check accumulates into `errors` rather than
|
|
18
|
+
# short-circuiting on the first one (see: "the owner fixes one thing per
|
|
19
|
+
# run").
|
|
20
|
+
module WorkGraphValidator
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def validate(intent_dir)
|
|
24
|
+
missing = []
|
|
25
|
+
errors = []
|
|
26
|
+
|
|
27
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
28
|
+
parsed = GraphFile.parse(graph_path)
|
|
29
|
+
errors.concat(parsed[:errors])
|
|
30
|
+
|
|
31
|
+
graph = parsed[:graph]
|
|
32
|
+
if graph.nil?
|
|
33
|
+
# G9 (intent 342, D6/D7/D12): a legacy intent that never got a
|
|
34
|
+
# graph.md still reads as a valid work graph, structurally, through
|
|
35
|
+
# the backward shim - but only when graph.md is genuinely absent.
|
|
36
|
+
# A graph.md that exists but is malformed (no "## Graph" section)
|
|
37
|
+
# must never fall through to the synthetic chain and hide its own
|
|
38
|
+
# error (D12's whole point). The fresh error list here is
|
|
39
|
+
# deliberate: `errors` above already holds the not-found error from
|
|
40
|
+
# `parsed[:errors]`, and reusing it would return ok: false for every
|
|
41
|
+
# legacy intent in the store.
|
|
42
|
+
if !File.exist?(graph_path) && ActionGraphShim.shape(intent_dir) == :actions
|
|
43
|
+
return validate_actions_shape(intent_dir)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
missing << "graph.md ## Graph section"
|
|
47
|
+
return { ok: false, missing: missing, errors: errors }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
nodes = graph[:nodes]
|
|
51
|
+
edges = graph[:edges]
|
|
52
|
+
declared_ids = edges.keys
|
|
53
|
+
|
|
54
|
+
# Intent-scope dangling check: every node must be declared with its own
|
|
55
|
+
# needs-line here (unlike a roadmap, which may have undeclared roots -
|
|
56
|
+
# that reconciliation is G4's, not this validator's).
|
|
57
|
+
(nodes - declared_ids).each { |id| errors << "needs target #{id.inspect} names no declared node" }
|
|
58
|
+
|
|
59
|
+
cyc = GraphEdges.cycle(edges)
|
|
60
|
+
errors << "cyclic graph, cannot validate: #{cyc.join(' > ')}" if cyc
|
|
61
|
+
|
|
62
|
+
node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
|
|
63
|
+
parsed_nodes = {}
|
|
64
|
+
paths_by_id = Hash.new { |h, k| h[k] = [] }
|
|
65
|
+
|
|
66
|
+
node_paths.each do |path|
|
|
67
|
+
nf = NodeFile.parse(path)
|
|
68
|
+
nf[:errors].each { |e| errors << "#{File.basename(path)}: #{e}" }
|
|
69
|
+
id = nf[:node]
|
|
70
|
+
next unless id
|
|
71
|
+
|
|
72
|
+
paths_by_id[id] << path
|
|
73
|
+
parsed_nodes[id] ||= nf
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
paths_by_id.each do |id, paths|
|
|
77
|
+
next unless paths.length > 1
|
|
78
|
+
|
|
79
|
+
errors << "node id #{id.inspect} claimed by more than one file: #{paths.map { |p| File.basename(p) }.join(', ')}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
declared_ids.each do |id|
|
|
83
|
+
missing << "nodes/ file for #{id}" unless parsed_nodes.key?(id)
|
|
84
|
+
errors << "declared node #{id.inspect} has no node file under nodes/" unless parsed_nodes.key?(id)
|
|
85
|
+
end
|
|
86
|
+
parsed_nodes.each_key do |id|
|
|
87
|
+
errors << "node file for #{id.inspect} exists but ## Graph declares no such node" unless declared_ids.include?(id)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
work_nodes = []
|
|
91
|
+
verify_nodes = []
|
|
92
|
+
|
|
93
|
+
declared_ids.each do |id|
|
|
94
|
+
nf = parsed_nodes[id]
|
|
95
|
+
next unless nf
|
|
96
|
+
|
|
97
|
+
case nf[:kind]
|
|
98
|
+
when "verify"
|
|
99
|
+
verify_nodes << id
|
|
100
|
+
errors << "verify node #{id.inspect} has no ## Criteria" unless has_section?(nf[:body], "## Criteria")
|
|
101
|
+
when "decision"
|
|
102
|
+
errors << "decision node #{id.inspect} has no ## Question with a question" unless has_nonblank_section?(nf[:body], "## Question")
|
|
103
|
+
when "research"
|
|
104
|
+
errors << "research node #{id.inspect} has no ## Deposit" unless has_section?(nf[:body], "## Deposit")
|
|
105
|
+
when "work"
|
|
106
|
+
work_nodes << id
|
|
107
|
+
errors << "work node #{id.inspect} has no ## Steps" unless has_section?(nf[:body], "## Steps")
|
|
108
|
+
errors << "work node #{id.inspect} has no ## Proven by" unless has_section?(nf[:body], "## Proven by")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
above_trivial_bar = work_nodes.length >= 2
|
|
113
|
+
if above_trivial_bar
|
|
114
|
+
verify_directive = parsed[:verify]
|
|
115
|
+
directive_ok = verify_directive && !verify_directive[:reason].to_s.empty?
|
|
116
|
+
|
|
117
|
+
unless directive_ok
|
|
118
|
+
attached = verify_nodes.select { |vid| node_touches_graph?(vid, edges) }
|
|
119
|
+
if attached.empty?
|
|
120
|
+
errors << "two or more work nodes require a verify node attached to the graph, or a verify: none reason=<text> directive"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
work_nodes.each do |id|
|
|
125
|
+
nf = parsed_nodes[id]
|
|
126
|
+
next unless nf
|
|
127
|
+
|
|
128
|
+
errors << "work node #{id.inspect} has no failure-mode matrix under a heading carrying its id" unless has_valid_matrix?(id, nf[:body])
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
verify_nodes.each do |id|
|
|
133
|
+
next if node_touches_graph?(id, edges)
|
|
134
|
+
|
|
135
|
+
errors << "verify node #{id.inspect} exists but no edge reaches it (attach it or drop it)"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
{ ok: errors.empty?, missing: missing, errors: errors }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A node "touches" the graph when some edge involves it on either side:
|
|
142
|
+
# it targets something, or something targets it. A verify node declared
|
|
143
|
+
# with "needs nothing" and targeted by nothing is fully isolated (review
|
|
144
|
+
# A13).
|
|
145
|
+
def node_touches_graph?(id, edges)
|
|
146
|
+
(edges[id] || []).any? || edges.values.any? { |targets| targets.include?(id) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def has_section?(body, heading_text)
|
|
150
|
+
NodeFile.split_by_headings(body.to_s).any? { |heading, _| heading.strip == heading_text }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def has_nonblank_section?(body, heading_text)
|
|
154
|
+
NodeFile.split_by_headings(body.to_s).any? { |heading, section| heading.strip == heading_text && !section.strip.empty? }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def heading_tokens(heading)
|
|
158
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The matrix table must sit under a heading whose tokens include the
|
|
162
|
+
# node's own id, and that heading must own at least one data row (review
|
|
163
|
+
# A1 - the same table-owning rule report_screen's resolver uses, so a
|
|
164
|
+
# node's own Proven-by cell is never "not recorded" the moment it ships).
|
|
165
|
+
def has_valid_matrix?(id, body)
|
|
166
|
+
NodeFile.split_by_headings(body.to_s).any? do |heading, section|
|
|
167
|
+
heading_tokens(heading).include?(id) && NodeFile.table_rows(section).any?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The structural check the synthetic (actions/-only) shape gets, and
|
|
172
|
+
# nothing more (D6): at least one node, unique ids, every needs target
|
|
173
|
+
# names a declared node, acyclic. Never the kind-section rules, the
|
|
174
|
+
# failure-mode matrix bar, or the verify-attachment bar - a legacy action
|
|
175
|
+
# file labels its headings "S1", or nothing at all, and was never asked
|
|
176
|
+
# to meet a bar written for a graph authored under 327.
|
|
177
|
+
def validate_actions_shape(intent_dir)
|
|
178
|
+
errors = []
|
|
179
|
+
graph = ActionGraphShim.view(intent_dir)[:graph] || { nodes: [], edges: {}, errors: [] }
|
|
180
|
+
nodes = graph[:nodes]
|
|
181
|
+
edges = graph[:edges]
|
|
182
|
+
|
|
183
|
+
errors << "actions/ yields no nodes" if nodes.empty?
|
|
184
|
+
# D16: uniqueness is checked over the node array, not edges.keys - a
|
|
185
|
+
# Hash key set is unique by construction, so checking edges.keys can
|
|
186
|
+
# never fire.
|
|
187
|
+
errors << "duplicate node ids in synthetic chain" if nodes.uniq.length != nodes.length
|
|
188
|
+
|
|
189
|
+
# D16: every needs target is checked against the declared node list,
|
|
190
|
+
# not the node list against its own key set (nodes - edges.keys, which
|
|
191
|
+
# can never differ since the builder mints edges.keys from nodes).
|
|
192
|
+
(edges.values.flatten.uniq - nodes).each do |id|
|
|
193
|
+
errors << "needs target #{id.inspect} names no declared node"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
cyc = GraphEdges.cycle(edges)
|
|
197
|
+
errors << "cyclic graph, cannot validate: #{cyc.join(' > ')}" if cyc
|
|
198
|
+
|
|
199
|
+
{ ok: errors.empty?, missing: [], errors: errors }
|
|
200
|
+
end
|
|
201
|
+
end
|