@opengsd/gsd-core 1.13.0 → 1.14.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-advisor-researcher.compact.md +85 -0
- package/agents/gsd-ai-researcher.compact.md +96 -0
- package/agents/gsd-assumptions-analyzer.compact.md +81 -0
- package/agents/gsd-code-fixer.compact.md +458 -0
- package/agents/gsd-code-fixer.md +5 -5
- package/agents/gsd-code-reviewer.compact.md +269 -0
- package/agents/gsd-code-reviewer.md +15 -3
- package/agents/gsd-codebase-mapper.compact.md +760 -0
- package/agents/gsd-debug-session-manager.compact.md +345 -0
- package/agents/gsd-doc-classifier.compact.md +192 -0
- package/agents/gsd-doc-synthesizer.compact.md +200 -0
- package/agents/gsd-doc-verifier.compact.md +143 -0
- package/agents/gsd-doc-writer.compact.md +440 -0
- package/agents/gsd-dom-verifier.compact.md +138 -0
- package/agents/gsd-domain-researcher.compact.md +141 -0
- package/agents/gsd-eval-auditor.compact.md +160 -0
- package/agents/gsd-eval-planner.compact.md +137 -0
- package/agents/gsd-framework-selector.compact.md +82 -0
- package/agents/gsd-integration-checker.compact.md +245 -0
- package/agents/gsd-intel-updater.compact.md +226 -0
- package/agents/gsd-mempalace-curator.compact.md +45 -0
- package/agents/gsd-nyquist-auditor.compact.md +179 -0
- package/agents/gsd-pattern-mapper.compact.md +275 -0
- package/agents/gsd-project-researcher.compact.md +587 -0
- package/agents/gsd-research-synthesizer.compact.md +212 -0
- package/agents/gsd-roadmapper.compact.md +454 -0
- package/agents/gsd-roadmapper.md +13 -0
- package/agents/gsd-security-auditor.compact.md +162 -0
- package/agents/gsd-ui-auditor.compact.md +404 -0
- package/agents/gsd-ui-checker.compact.md +277 -0
- package/agents/gsd-ui-researcher.compact.md +282 -0
- package/agents/gsd-user-profiler.compact.md +108 -0
- package/bin/install.js +206 -68
- package/commands/gsd/cleanup.md +1 -0
- package/commands/gsd/code-review.md +2 -1
- package/commands/gsd/complete-milestone.md +1 -0
- package/commands/gsd/config.md +1 -0
- package/commands/gsd/debug.md +1 -0
- package/commands/gsd/graphify.md +1 -0
- package/commands/gsd/health.md +1 -0
- package/commands/gsd/mempalace-capture.md +1 -0
- package/commands/gsd/mempalace-recall.md +1 -0
- package/commands/gsd/new-milestone.md +1 -0
- package/commands/gsd/new-project.md +1 -0
- package/commands/gsd/next.md +1 -0
- package/commands/gsd/pause-work.md +1 -0
- package/commands/gsd/phase.md +1 -0
- package/commands/gsd/pr-branch.md +1 -0
- package/commands/gsd/resume-work.md +1 -0
- package/commands/gsd/review-backlog.md +1 -0
- package/commands/gsd/settings.md +2 -1
- package/commands/gsd/stats.md +1 -0
- package/commands/gsd/thread.md +1 -0
- package/commands/gsd/workspace.md +1 -0
- package/commands/gsd/workstreams.md +1 -0
- package/gsd-core/bin/check-latest-version.cjs +8 -3
- package/gsd-core/bin/gsd-tools.cjs +338 -125
- package/gsd-core/bin/lib/adr-parser.cjs +1 -1
- package/gsd-core/bin/lib/artifacts.cjs +2 -1
- package/gsd-core/bin/lib/audit.cjs +39 -22
- package/gsd-core/bin/lib/broken-windows.cjs +168 -49
- package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
- package/gsd-core/bin/lib/capability-loader.cjs +135 -1
- package/gsd-core/bin/lib/capability-registry.cjs +79 -67
- package/gsd-core/bin/lib/capability-source.cjs +19 -2
- package/gsd-core/bin/lib/capability-validator.cjs +14 -1
- package/gsd-core/bin/lib/check-command-router.cjs +113 -36
- package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
- package/gsd-core/bin/lib/commands.cjs +650 -72
- package/gsd-core/bin/lib/config-loader.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +153 -38
- package/gsd-core/bin/lib/coverage.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +137 -34
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
- package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
- package/gsd-core/bin/lib/init.cjs +409 -47
- package/gsd-core/bin/lib/install-engine.cjs +16 -3
- package/gsd-core/bin/lib/install-profiles.cjs +14 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
- package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
- package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
- package/gsd-core/bin/lib/milestone.cjs +19 -8
- package/gsd-core/bin/lib/model-resolver.cjs +101 -10
- package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
- package/gsd-core/bin/lib/phase-id.cjs +161 -22
- package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
- package/gsd-core/bin/lib/phase.cjs +167 -63
- package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
- package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
- package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
- package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
- package/gsd-core/bin/lib/quick-batch.cjs +1 -1
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
- package/gsd-core/bin/lib/research-store.cjs +11 -12
- package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
- package/gsd-core/bin/lib/roadmap.cjs +108 -14
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
- package/gsd-core/bin/lib/security.cjs +126 -7
- package/gsd-core/bin/lib/state-document.cjs +130 -28
- package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
- package/gsd-core/bin/lib/state-transition.cjs +142 -28
- package/gsd-core/bin/lib/state.cjs +223 -27
- package/gsd-core/bin/lib/surface.cjs +60 -2
- package/gsd-core/bin/lib/task-command-router.cjs +12 -6
- package/gsd-core/bin/lib/uat.cjs +1 -1
- package/gsd-core/bin/lib/update-context.cjs +30 -24
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
- package/gsd-core/bin/lib/verification.cjs +47 -15
- package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
- package/gsd-core/bin/lib/verify.cjs +188 -23
- package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
- package/gsd-core/references/compact-content-gate.md +66 -0
- package/gsd-core/references/loop-hook-dispatch.md +18 -0
- package/gsd-core/references/model-profiles.md +12 -3
- package/gsd-core/references/planning-config.md +3 -0
- package/gsd-core/references/tdd.md +5 -2
- package/gsd-core/references/thinking-models-planning.md +18 -2
- package/gsd-core/references/verification-patterns.md +17 -4
- package/gsd-core/references/worktree-path-safety.md +112 -2
- package/gsd-core/templates/README.md +7 -1
- package/gsd-core/templates/state.md +6 -3
- package/gsd-core/templates/summary.compact.md +212 -0
- package/gsd-core/templates/user-setup.compact.md +199 -0
- package/gsd-core/templates/user-setup.md +0 -9
- package/gsd-core/workflows/add-todo.md +3 -2
- package/gsd-core/workflows/autonomous.md +13 -10
- package/gsd-core/workflows/check-todos.md +4 -2
- package/gsd-core/workflows/cleanup.md +3 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
- package/gsd-core/workflows/code-review-fix.md +3 -3
- package/gsd-core/workflows/code-review.md +156 -30
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
- package/gsd-core/workflows/complete-milestone.md +39 -262
- package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
- package/gsd-core/workflows/docs-update.md +14 -155
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
- package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
- package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
- package/gsd-core/workflows/execute-phase.md +53 -152
- package/gsd-core/workflows/execute-plan.md +20 -7
- package/gsd-core/workflows/help/modes/full.compact.md +398 -0
- package/gsd-core/workflows/help.md +1 -1
- package/gsd-core/workflows/map-codebase.md +50 -3
- package/gsd-core/workflows/new-milestone.md +54 -12
- package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
- package/gsd-core/workflows/new-project.md +32 -202
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
- package/gsd-core/workflows/plan-phase.md +22 -181
- package/gsd-core/workflows/pr-branch.md +19 -7
- package/gsd-core/workflows/quick.md +8 -1
- package/gsd-core/workflows/reapply-patches.md +77 -3
- package/gsd-core/workflows/settings.md +18 -5
- package/gsd-core/workflows/update.md +7 -5
- package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
- package/gsd-core/workflows/verify-work.md +20 -180
- package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
- package/hooks/dist/gsd-context-monitor.js +88 -15
- package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
- package/hooks/dist/gsd-secret-read-guard.js +44 -18
- package/hooks/dist/gsd-statusline.js +11 -7
- package/hooks/dist/gsd-validate-commit.sh +34 -4
- package/hooks/dist/gsd-worktree-path-guard.js +25 -14
- package/hooks/dist/gsd-write-guard.js +46 -1
- package/hooks/dist/lib/dispatch-identity.js +187 -0
- package/hooks/dist/lib/filename-classification.js +64 -0
- package/hooks/dist/lib/isolation-deny-reason.js +53 -1
- package/hooks/dist/lib/isolation-sentinel.js +58 -19
- package/hooks/gsd-agent-isolation-guard.js +42 -16
- package/hooks/gsd-context-monitor.js +88 -15
- package/hooks/gsd-cursor-subagent-start.js +34 -14
- package/hooks/gsd-secret-read-guard.js +44 -18
- package/hooks/gsd-statusline.js +11 -7
- package/hooks/gsd-validate-commit.sh +34 -4
- package/hooks/gsd-worktree-path-guard.js +25 -14
- package/hooks/gsd-write-guard.js +46 -1
- package/hooks/lib/dispatch-identity.js +187 -0
- package/hooks/lib/filename-classification.js +64 -0
- package/hooks/lib/isolation-deny-reason.js +53 -1
- package/hooks/lib/isolation-sentinel.js +58 -19
- package/package.json +10 -6
- package/scripts/benchmark-compact-content-variants.cjs +298 -0
- package/scripts/benchmark-compact-content.cjs +368 -0
- package/scripts/check-contract-drift.cjs +4 -1
- package/scripts/check-env.cjs +36 -8
- package/scripts/check-glossary-refs.cjs +25 -21
- package/scripts/ci-next-health.cjs +271 -0
- package/scripts/ci-prepare-test-scope.cjs +7 -7
- package/scripts/ci-test-scope.cjs +126 -20
- package/scripts/ci-timeout-report.cjs +1 -1
- package/scripts/diff-touches-shipped-paths.cjs +1 -1
- package/scripts/docs-guard-registry.cjs +7 -2
- package/scripts/gen-adr-index.cjs +8 -2
- package/scripts/gen-inventory-manifest.cjs +12 -0
- package/scripts/gen-platform-conformance-tier.cjs +557 -0
- package/scripts/lib/drift-scan.cjs +1 -1
- package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
- package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
- package/scripts/lib/suite-detection.cjs +32 -0
- package/scripts/lint-allowed-tools-parity.cjs +221 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
- package/scripts/lint-phase-id-drift.cjs +338 -13
- package/scripts/lint-response-language-coverage.cjs +9 -3
- package/scripts/lint-source-test-name-collision.cjs +1 -1
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/lint-vendored-deps.cjs +128 -17
- package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
- package/scripts/prompt-injection-scan.sh +14 -0
- package/scripts/workflow-size.cjs +139 -0
- package/skills/gsd-cleanup/SKILL.md +1 -0
- package/skills/gsd-code-review/SKILL.md +2 -1
- package/skills/gsd-complete-milestone/SKILL.md +1 -0
- package/skills/gsd-config/SKILL.md +1 -0
- package/skills/gsd-debug/SKILL.md +1 -0
- package/skills/gsd-graphify/SKILL.md +1 -0
- package/skills/gsd-health/SKILL.md +1 -0
- package/skills/gsd-mempalace-capture/SKILL.md +1 -0
- package/skills/gsd-mempalace-recall/SKILL.md +1 -0
- package/skills/gsd-new-milestone/SKILL.md +1 -0
- package/skills/gsd-new-project/SKILL.md +1 -0
- package/skills/gsd-next/SKILL.md +1 -0
- package/skills/gsd-pause-work/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -0
- package/skills/gsd-pr-branch/SKILL.md +1 -0
- package/skills/gsd-resume-work/SKILL.md +1 -0
- package/skills/gsd-review-backlog/SKILL.md +1 -0
- package/skills/gsd-settings/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +1 -0
- package/skills/gsd-thread/SKILL.md +1 -0
- package/skills/gsd-workspace/SKILL.md +1 -0
- package/skills/gsd-workstreams/SKILL.md +1 -0
- package/vscode/package.json +1 -1
- package/gsd-core/templates/claude-md.md +0 -145
- package/gsd-core/templates/codebase/concerns.md +0 -310
- package/gsd-core/templates/codebase/conventions.md +0 -307
- package/gsd-core/templates/codebase/integrations.md +0 -280
- package/gsd-core/templates/codebase/structure.md +0 -285
- package/gsd-core/templates/codebase/testing.md +0 -480
- package/gsd-core/templates/debug-subagent-prompt.md +0 -91
- package/gsd-core/templates/discovery.md +0 -146
|
@@ -20,6 +20,34 @@
|
|
|
20
20
|
* directly above). Sites that build their regex from `PHASE_NUMBER_TOKEN_SOURCE`
|
|
21
21
|
* carry no literal grammar and pass automatically.
|
|
22
22
|
*
|
|
23
|
+
* #4634 extends the same pattern with two more detectors:
|
|
24
|
+
*
|
|
25
|
+
* - name-validity-guard drift: `hasNameableContent(s)` in `src/roadmap-parser.cts`
|
|
26
|
+
* is the sole owner of the "does this string have nameable content" predicate
|
|
27
|
+
* (`/[\p{L}\p{N}]/u.test(s)`). Any other `src/**` file re-deriving that exact
|
|
28
|
+
* character class (regex-literal or `new RegExp` template form) instead of
|
|
29
|
+
* calling `hasNameableContent` is drift, sanctioned the same way as the token
|
|
30
|
+
* and bracket rules (`// phase-id-owner:` on the nearest preceding non-blank
|
|
31
|
+
* line), with a line-level escape for a line that already calls
|
|
32
|
+
* `hasNameableContent(`.
|
|
33
|
+
*
|
|
34
|
+
* - shell phase-number-arithmetic ban: `$((10#...))` base-10-forced arithmetic
|
|
35
|
+
* inside `gsd-core/workflows/**\/*.md` and `gsd-core/references/**\/*.md` breaks
|
|
36
|
+
* on decimal or multi-segment phase ids and is banned outright. This scan runs
|
|
37
|
+
* over markdown, not `.cts` source, so its sanction is an HTML comment on the
|
|
38
|
+
* nearest preceding non-blank line: `<!-- phase-id-owner: <reason> -->`.
|
|
39
|
+
*
|
|
40
|
+
* - branch-slug fallback drift: a `.replace('{slug}', ... || 'phase')` call
|
|
41
|
+
* silently substitutes the literal string `'phase'` when a phase's slug
|
|
42
|
+
* can't be derived, producing a non-identifying branch name like
|
|
43
|
+
* `gsd/phase-08-phase` (#4126, now fixed via the shared renderPhaseBranchName
|
|
44
|
+
* owner in phase-id.cts, consumed by both prior call sites). Sanctioned
|
|
45
|
+
* the same way as the token/bracket/name-validity rules (`// phase-id-owner:`
|
|
46
|
+
* on the nearest preceding non-blank line), with a line-level escape for a
|
|
47
|
+
* line that already calls `renderPhaseBranchName(`. Unlike the other
|
|
48
|
+
* `.cts`-scanning rules, this one has no per-file exemption — it is a banned
|
|
49
|
+
* anti-pattern everywhere, not a grammar with one legitimate owner site.
|
|
50
|
+
*
|
|
23
51
|
* Detection is intentionally NARROW: only the contiguous canonical token
|
|
24
52
|
* (`\d+[A-Z]?(?:\.\d+)*`, its `[A-Za-z]` and `[.-]` near-variants, in both
|
|
25
53
|
* regex-literal `\d` and `new RegExp` template `\\d` escaping) is drift. Bare
|
|
@@ -82,6 +110,18 @@ const BRACKET_CODE_DRIFT_RE = /\[A-Z(?:a-z)?\]\[A-Z(?:a-z)?0-9_\]\*/;
|
|
|
82
110
|
const BRACKET_OWNER_HINT =
|
|
83
111
|
'BRACKET_PROJECT_CODE_SRC / BRACKET_ID_SRC / bracketMilestoneIntroSrcFor / BRACKET_MILESTONE_INTRO_CAPTURING_SRC';
|
|
84
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Pure: true if the nearest preceding non-blank line to `lines[i]` is a
|
|
115
|
+
* dedicated sanction comment matching `ownerRe`. Shared by every detector in
|
|
116
|
+
* this file so the "how do you sanction a finding" walk has one owner instead
|
|
117
|
+
* of four independent copies that could silently diverge.
|
|
118
|
+
*/
|
|
119
|
+
function isSanctionedByPrecedingComment(lines, i, ownerRe) {
|
|
120
|
+
let j = i - 1;
|
|
121
|
+
while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line
|
|
122
|
+
return j >= 0 && ownerRe.test(lines[j]);
|
|
123
|
+
}
|
|
124
|
+
|
|
85
125
|
/**
|
|
86
126
|
* Pure: find every literal re-derivation of the canonical phase-number token in
|
|
87
127
|
* `text` that is NOT sanctioned. A site is sanctioned when the nearest preceding
|
|
@@ -100,9 +140,7 @@ function findPhaseIdRegexDrift(text) {
|
|
|
100
140
|
const m = TOKEN_DRIFT_RE.exec(line);
|
|
101
141
|
if (!m) continue;
|
|
102
142
|
if (line.includes(CANON_REF)) continue;
|
|
103
|
-
|
|
104
|
-
while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line
|
|
105
|
-
if (j >= 0 && OWNER_RE.test(lines[j])) continue;
|
|
143
|
+
if (isSanctionedByPrecedingComment(lines, i, OWNER_RE)) continue;
|
|
106
144
|
out.push({ line: i + 1, found: m[0] });
|
|
107
145
|
}
|
|
108
146
|
return out;
|
|
@@ -122,21 +160,249 @@ function findBracketGrammarDrift(text) {
|
|
|
122
160
|
for (let i = 0; i < lines.length; i++) {
|
|
123
161
|
const m = BRACKET_CODE_DRIFT_RE.exec(lines[i]);
|
|
124
162
|
if (!m) continue;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
163
|
+
if (isSanctionedByPrecedingComment(lines, i, OWNER_RE)) continue;
|
|
164
|
+
out.push({ line: i + 1, found: m[0] });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// #4634: the name-validity-guard grammar — `hasNameableContent(s)` in
|
|
170
|
+
// `src/roadmap-parser.cts` is `/[\p{L}\p{N}]/u.test(s)`. Tolerates both the
|
|
171
|
+
// regex-literal single-backslash form and the doubled-backslash template
|
|
172
|
+
// form (`new RegExp('[\\p{L}\\p{N}]'`), mirroring how TOKEN_DRIFT_RE tolerates
|
|
173
|
+
// both escapings.
|
|
174
|
+
const NAME_VALIDITY_DRIFT_RE = /\[\\{1,2}p\{L\}\\{1,2}p\{N\}\]/;
|
|
175
|
+
const NAME_VALIDITY_CANON_REF = 'hasNameableContent(';
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pure: find every literal re-derivation of the canonical name-validity
|
|
179
|
+
* character class in `text` that is NOT sanctioned. Same sanction mechanism
|
|
180
|
+
* as the token/bracket rules — a dedicated `// phase-id-owner:` comment on
|
|
181
|
+
* the nearest preceding non-blank line — plus a line-level escape for a line
|
|
182
|
+
* that already calls `hasNameableContent(`. Returns [{ line, found }].
|
|
183
|
+
*/
|
|
184
|
+
function findNameValidityDrift(text) {
|
|
185
|
+
const out = [];
|
|
186
|
+
const lines = text.split('\n');
|
|
187
|
+
for (let i = 0; i < lines.length; i++) {
|
|
188
|
+
const line = lines[i];
|
|
189
|
+
const m = NAME_VALIDITY_DRIFT_RE.exec(line);
|
|
190
|
+
if (!m) continue;
|
|
191
|
+
if (line.includes(NAME_VALIDITY_CANON_REF)) continue;
|
|
192
|
+
if (isSanctionedByPrecedingComment(lines, i, OWNER_RE)) continue;
|
|
193
|
+
out.push({ line: i + 1, found: m[0] });
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// #4634: the branch-slug fallback anti-pattern (#4126) — a
|
|
199
|
+
// `.replace('{slug}', ... || 'phase')` call silently falls back to the
|
|
200
|
+
// literal string `'phase'` when a phase's slug can't be derived, producing a
|
|
201
|
+
// non-identifying branch name like `gsd/phase-08-phase`. Now fixed at both
|
|
202
|
+
// prior call sites (commands.cts, init.cts) via the shared
|
|
203
|
+
// `renderPhaseBranchName` owner in phase-id.cts; this rule is the ratchet
|
|
204
|
+
// against a THIRD site reintroducing the inline fallback. Deliberately
|
|
205
|
+
// narrow: it requires the literal `'phase'` fallback on the same line as the
|
|
206
|
+
// `{slug}` template token, so it does NOT match the sibling milestone-branch
|
|
207
|
+
// fallback (`|| 'milestone'`), which is a different, correct-as-is case.
|
|
208
|
+
const BRANCH_SLUG_FALLBACK_DRIFT_RE = /\{slug\}'.*\|\|\s*'phase'/;
|
|
209
|
+
|
|
210
|
+
// The canonical fix is `renderPhaseBranchName(...)`. There is no "owner file"
|
|
211
|
+
// for this rule the way there is for the token/bracket/name-validity
|
|
212
|
+
// grammars above — it is a banned anti-pattern everywhere, so no per-file
|
|
213
|
+
// exemption exists.
|
|
214
|
+
const BRANCH_SLUG_FALLBACK_CANON_REF = 'renderPhaseBranchName(';
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Pure: find every unsanctioned branch-slug `|| 'phase'` fallback in `text`.
|
|
218
|
+
* Same sanction mechanism as the token/bracket/name-validity rules — a
|
|
219
|
+
* dedicated `// phase-id-owner:` comment on the nearest preceding non-blank
|
|
220
|
+
* line — plus a line-level escape for a line that already calls
|
|
221
|
+
* `renderPhaseBranchName(`. Returns [{ line, found }].
|
|
222
|
+
*/
|
|
223
|
+
function findBranchSlugFallbackDrift(text) {
|
|
224
|
+
const out = [];
|
|
225
|
+
const lines = text.split('\n');
|
|
226
|
+
for (let i = 0; i < lines.length; i++) {
|
|
227
|
+
const line = lines[i];
|
|
228
|
+
const m = BRANCH_SLUG_FALLBACK_DRIFT_RE.exec(line);
|
|
229
|
+
if (!m) continue;
|
|
230
|
+
if (line.includes(BRANCH_SLUG_FALLBACK_CANON_REF)) continue;
|
|
231
|
+
if (isSanctionedByPrecedingComment(lines, i, OWNER_RE)) continue;
|
|
232
|
+
out.push({ line: i + 1, found: m[0] });
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// #4634: ban base-10-forced shell arithmetic (`$((10#...))`) on a variable
|
|
238
|
+
// that still carries a possibly-decimal/multi-segment phase id — this
|
|
239
|
+
// construct is exactly the pattern that breaks on a value like `08.5`. The
|
|
240
|
+
// capture group grabs the token immediately inside the parens (after an
|
|
241
|
+
// optional `$` and/or `{`, stripping a trailing `}`) so callers can inspect
|
|
242
|
+
// *which* variable is being coerced, not merely that the substring occurred.
|
|
243
|
+
//
|
|
244
|
+
// Refined post-#4619: the original blunt "ban `$((10#` outright" version
|
|
245
|
+
// over-fired on three false-positive classes once #4619's fix landed:
|
|
246
|
+
// 1. Prose mentioning the literal pattern in a full-line `#`-comment
|
|
247
|
+
// (filtered by the caller, not this regex — see below).
|
|
248
|
+
// 2. `$((10#$PHASE_INT))` / `$((10#$SPOT_PHASE_INT))` — arithmetic on the
|
|
249
|
+
// NOW-safe variable the #4619 fix produces via `PHASE_INT=${PHASE_NUMBER%%.*}`;
|
|
250
|
+
// a `%%.*`-stripped value can never contain a dot, so base-10 arithmetic
|
|
251
|
+
// on it can never hit the #4619 syntax-error class. Any name ending in
|
|
252
|
+
// `_INT` (case-insensitive) is that established "already reduced to a
|
|
253
|
+
// safe integer" convention.
|
|
254
|
+
// 3. `$((10#{plan_padded}))` / `$((10#${PLAN_ID}))` — plan ids are plain
|
|
255
|
+
// integers and were never in scope; this rule only polices variables
|
|
256
|
+
// that carry a *phase* id.
|
|
257
|
+
// So a match is only a violation when the captured name contains `phase`
|
|
258
|
+
// case-insensitively (it is phase-carrying) AND does not end in `_int`
|
|
259
|
+
// case-insensitively (it has not already been reduced to a safe integer).
|
|
260
|
+
const SHELL_PHASE_ARITH_DRIFT_RE = /\$\(\(\s*10#\$?\{?([A-Za-z0-9_]+)\}?/;
|
|
261
|
+
|
|
262
|
+
// A markdown comment can't easily carry a `//` line, so the sanction for the
|
|
263
|
+
// shell-arithmetic rule is an HTML comment on the nearest preceding non-blank
|
|
264
|
+
// line: `<!-- phase-id-owner: <reason> -->`.
|
|
265
|
+
const MD_OWNER_RE = /^\s*<!--.*phase-id-owner:/;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Pure: find every unsanctioned `$((10#...))` base-10-forced shell arithmetic
|
|
269
|
+
* site in `text` that still coerces an un-reduced phase-carrying variable.
|
|
270
|
+
* Skips full-line `#` comments outright (pure prose mentioning the pattern,
|
|
271
|
+
* not executable code), and skips any captured variable name that either
|
|
272
|
+
* doesn't contain `phase` (never in scope — e.g. plan ids) or already ends
|
|
273
|
+
* in `_int` (the #4619-fix convention for "safely stripped to an integer").
|
|
274
|
+
* Sanctioned by an HTML comment `<!-- phase-id-owner: ... -->` on the
|
|
275
|
+
* nearest preceding non-blank line. Returns [{ line, found }].
|
|
276
|
+
*/
|
|
277
|
+
function findShellPhaseArithDrift(text) {
|
|
278
|
+
const out = [];
|
|
279
|
+
const lines = text.split('\n');
|
|
280
|
+
for (let i = 0; i < lines.length; i++) {
|
|
281
|
+
const line = lines[i];
|
|
282
|
+
if (/^\s*#/.test(line)) continue;
|
|
283
|
+
const m = SHELL_PHASE_ARITH_DRIFT_RE.exec(line);
|
|
284
|
+
if (!m) continue;
|
|
285
|
+
const name = m[1];
|
|
286
|
+
if (!/phase/i.test(name)) continue;
|
|
287
|
+
if (/_int$/i.test(name)) continue;
|
|
288
|
+
if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
|
|
289
|
+
out.push({ line: i + 1, found: m[0] });
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// #4634: the markdown scan roots — shell embedded in workflow/reference docs.
|
|
295
|
+
const MD_SCAN_DIRS = [path.join('gsd-core', 'workflows'), path.join('gsd-core', 'references')];
|
|
296
|
+
|
|
297
|
+
// #4568 (epic #4634): the single-segment phase regex ban scans a THIRD root,
|
|
298
|
+
// `agents/**/*.md`, that the #4619 shell-arithmetic extension above never
|
|
299
|
+
// touched — the gsd-code-fixer agent prompts re-derive the phase-number
|
|
300
|
+
// grammar too. Reuses the same `walkMd` walker as the shell-arith scan.
|
|
301
|
+
const SINGLE_SEGMENT_SCAN_DIRS = [...MD_SCAN_DIRS, 'agents'];
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Scan `gsd-core/workflows/**\/*.md` and `gsd-core/references/**\/*.md` for
|
|
305
|
+
* unsanctioned `$((10#...))` shell arithmetic. Returns [{ file, line, found }]
|
|
306
|
+
* with repo-relative paths.
|
|
307
|
+
*/
|
|
308
|
+
function scanMarkdownShellArith(root) {
|
|
309
|
+
const violations = [];
|
|
310
|
+
for (const dir of MD_SCAN_DIRS) {
|
|
311
|
+
for (const file of walkMd(path.join(root, dir), [])) {
|
|
312
|
+
const rel = path.relative(root, file);
|
|
313
|
+
let text;
|
|
314
|
+
try {
|
|
315
|
+
text = fs.readFileSync(file, 'utf8');
|
|
316
|
+
} catch {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
for (const d of findShellPhaseArithDrift(text)) {
|
|
320
|
+
violations.push({ file: rel, kind: 'shell-arith', ...d });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return violations;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// #4568 (epic #4634): ban the single-optional-dotted-segment phase regex
|
|
328
|
+
// shape `[0-9]+(\.[0-9]+)?` (and its `\d`/doubled-backslash near-variants)
|
|
329
|
+
// outright — this is exactly the grammar that hard-rejects or silently
|
|
330
|
+
// truncates a 3-or-more-segment phase id like `23.1.2`. The canonical
|
|
331
|
+
// grammar (`src/phase-id.cts`) uses the unbounded `(?:\.\d+)*` form; shell
|
|
332
|
+
// snippets embedded in markdown can't import that module, so textual parity
|
|
333
|
+
// (`*` in place of `?`) is the fix, and this rule is the ratchet against a
|
|
334
|
+
// future site re-deriving the bounded form. Deliberately narrow to the
|
|
335
|
+
// bounded ONE-optional-segment shape — the fixed `*`-form is not flagged.
|
|
336
|
+
const SINGLE_SEGMENT_PHASE_DRIFT_RE =
|
|
337
|
+
/(?:\\{1,2}d|\[0-9\])\+\(\\{1,2}\.(?:\\{1,2}d|\[0-9\])\+\)\?/;
|
|
338
|
+
|
|
339
|
+
// A single-segment shape like `[0-9]+(\.[0-9]+)?` is not inherently
|
|
340
|
+
// phase-specific (e.g. it could describe a version number), so the rule
|
|
341
|
+
// only fires on a line whose text plausibly carries a phase-number
|
|
342
|
+
// variable — a case-insensitive `phase` substring anywhere on the line,
|
|
343
|
+
// mirroring the phase-carrying filter `findShellPhaseArithDrift` already
|
|
344
|
+
// applies to its own variable-name capture.
|
|
345
|
+
const PHASE_CARRYING_LINE_RE = /phase/i;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Pure: find every unsanctioned single-optional-dotted-segment phase regex
|
|
349
|
+
* in `text`, restricted to lines that plausibly carry a phase-number
|
|
350
|
+
* variable. Sanctioned by an HTML comment `<!-- phase-id-owner: ... -->` on
|
|
351
|
+
* the nearest preceding non-blank line (same convention as the shell-arith
|
|
352
|
+
* rule). Returns [{ line, found }].
|
|
353
|
+
*/
|
|
354
|
+
function findSingleSegmentPhaseRegexDrift(text) {
|
|
355
|
+
const out = [];
|
|
356
|
+
const lines = text.split('\n');
|
|
357
|
+
for (let i = 0; i < lines.length; i++) {
|
|
358
|
+
const line = lines[i];
|
|
359
|
+
const m = SINGLE_SEGMENT_PHASE_DRIFT_RE.exec(line);
|
|
360
|
+
if (!m) continue;
|
|
361
|
+
if (!PHASE_CARRYING_LINE_RE.test(line)) continue;
|
|
362
|
+
if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
|
|
128
363
|
out.push({ line: i + 1, found: m[0] });
|
|
129
364
|
}
|
|
130
365
|
return out;
|
|
131
366
|
}
|
|
132
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Scan `gsd-core/workflows/**\/*.md`, `gsd-core/references/**\/*.md`, and
|
|
370
|
+
* `agents/**\/*.md` for unsanctioned single-optional-dotted-segment phase
|
|
371
|
+
* regexes. Returns [{ file, line, found }] with repo-relative paths.
|
|
372
|
+
*/
|
|
373
|
+
function scanMarkdownSingleSegmentPhaseRegex(root) {
|
|
374
|
+
const violations = [];
|
|
375
|
+
for (const dir of SINGLE_SEGMENT_SCAN_DIRS) {
|
|
376
|
+
for (const file of walkMd(path.join(root, dir), [])) {
|
|
377
|
+
const rel = path.relative(root, file);
|
|
378
|
+
let text;
|
|
379
|
+
try {
|
|
380
|
+
text = fs.readFileSync(file, 'utf8');
|
|
381
|
+
} catch {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
for (const d of findSingleSegmentPhaseRegexDrift(text)) {
|
|
385
|
+
violations.push({ file: rel, kind: 'single-segment-phase-regex', ...d });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return violations;
|
|
390
|
+
}
|
|
391
|
+
|
|
133
392
|
// Authored TypeScript source only (the generated bin/lib/*.cjs mirror it).
|
|
134
393
|
const SCAN_DIRS = ['src'];
|
|
135
394
|
const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
|
|
136
395
|
// The canonical owner defines the grammar; it is exempt by construction.
|
|
137
396
|
const EXEMPT = new Set([path.join('src', 'phase-id.cts')]);
|
|
138
397
|
|
|
139
|
-
|
|
398
|
+
// #4634: the name-validity-guard rule owns a DIFFERENT file (roadmap-parser.cts
|
|
399
|
+
// defines `hasNameableContent`), so it needs its own exemption set — the
|
|
400
|
+
// token/bracket rules above must NOT start exempting roadmap-parser.cts too,
|
|
401
|
+
// since it is not their owner.
|
|
402
|
+
const NAME_VALIDITY_EXEMPT = new Set([path.join('src', 'roadmap-parser.cts')]);
|
|
403
|
+
|
|
404
|
+
function walk(dir, acc, ext) {
|
|
405
|
+
const extSet = ext || SCAN_EXT;
|
|
140
406
|
let entries;
|
|
141
407
|
try {
|
|
142
408
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -147,14 +413,22 @@ function walk(dir, acc) {
|
|
|
147
413
|
const full = path.join(dir, entry.name);
|
|
148
414
|
if (entry.isDirectory()) {
|
|
149
415
|
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
|
|
150
|
-
walk(full, acc);
|
|
151
|
-
} else if (entry.isFile() &&
|
|
416
|
+
walk(full, acc, extSet);
|
|
417
|
+
} else if (entry.isFile() && extSet.has(path.extname(entry.name))) {
|
|
152
418
|
acc.push(full);
|
|
153
419
|
}
|
|
154
420
|
}
|
|
155
421
|
return acc;
|
|
156
422
|
}
|
|
157
423
|
|
|
424
|
+
// #4634: markdown scan for the shell phase-arithmetic ban walks a disjoint set
|
|
425
|
+
// of roots/extensions from the src/**/*.cts scan above, so it gets its own thin
|
|
426
|
+
// wrapper over the same `walk` rather than a parallel tree-walker.
|
|
427
|
+
const MD_EXT = new Set(['.md']);
|
|
428
|
+
function walkMd(dir, acc) {
|
|
429
|
+
return walk(dir, acc, MD_EXT);
|
|
430
|
+
}
|
|
431
|
+
|
|
158
432
|
// ─── #2761 M4: the heading-baseline selector census ────────────────────────
|
|
159
433
|
//
|
|
160
434
|
// `phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.<MODE>)` is the other half
|
|
@@ -239,23 +513,63 @@ function scanRepo(root) {
|
|
|
239
513
|
for (const d of findBracketGrammarDrift(text)) {
|
|
240
514
|
violations.push({ file: rel, kind: 'bracket', ...d });
|
|
241
515
|
}
|
|
516
|
+
// #4634: name-validity-guard drift, exempting only its own owner file.
|
|
517
|
+
if (!NAME_VALIDITY_EXEMPT.has(rel)) {
|
|
518
|
+
for (const d of findNameValidityDrift(text)) {
|
|
519
|
+
violations.push({ file: rel, kind: 'name-validity', ...d });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// #4634: branch-slug fallback anti-pattern, exempt nowhere.
|
|
523
|
+
for (const d of findBranchSlugFallbackDrift(text)) {
|
|
524
|
+
violations.push({ file: rel, kind: 'branch-slug-fallback', ...d });
|
|
525
|
+
}
|
|
242
526
|
}
|
|
243
527
|
}
|
|
244
528
|
return violations;
|
|
245
529
|
}
|
|
246
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Scan EVERYTHING this seam guards: the `src/**\/*.cts` token/bracket/
|
|
533
|
+
* name-validity invariants (`scanRepo`) plus the markdown shell
|
|
534
|
+
* phase-arithmetic ban (`scanMarkdownShellArith`). This is what the CLI runs;
|
|
535
|
+
* `scanRepo` alone stays narrowly scoped to its original src/** contract so
|
|
536
|
+
* a live, separately-tracked markdown defect (#4619) cannot make the
|
|
537
|
+
* pinned-clean `scanRepo` test spuriously fail.
|
|
538
|
+
*/
|
|
539
|
+
function scanAll(root) {
|
|
540
|
+
return [
|
|
541
|
+
...scanRepo(root),
|
|
542
|
+
...scanMarkdownShellArith(root),
|
|
543
|
+
...scanMarkdownSingleSegmentPhaseRegex(root),
|
|
544
|
+
];
|
|
545
|
+
}
|
|
546
|
+
|
|
247
547
|
function main() {
|
|
248
548
|
const root = path.join(__dirname, '..');
|
|
249
|
-
const violations =
|
|
549
|
+
const violations = scanAll(root);
|
|
250
550
|
if (violations.length === 0) {
|
|
251
|
-
process.stdout.write(
|
|
551
|
+
process.stdout.write(
|
|
552
|
+
'ok phase-id-drift: no unsanctioned phase-token, bracket-grammar, name-validity, ' +
|
|
553
|
+
'branch-slug-fallback, or shell phase-arithmetic re-derivations found\n',
|
|
554
|
+
);
|
|
252
555
|
return;
|
|
253
556
|
}
|
|
254
557
|
process.stderr.write('phase-id-drift: literal re-derivation(s) of a canonical grammar found.\n');
|
|
255
558
|
process.stderr.write(`Build the regex from phase-id.cjs \`${CANON_REF}\` (or phaseMarkdownRegexSource for a\n`);
|
|
256
559
|
process.stderr.write(`known number) for the phase-number token, or from ${BRACKET_OWNER_HINT}\n`);
|
|
257
|
-
process.stderr.write('for the bracket grammar
|
|
258
|
-
process.stderr.write('
|
|
560
|
+
process.stderr.write('for the bracket grammar, or call `hasNameableContent(` (src/roadmap-parser.cts) for\n');
|
|
561
|
+
process.stderr.write('the name-validity predicate — or sanction the site with a dedicated\n');
|
|
562
|
+
process.stderr.write('`// phase-id-owner: <reason>` comment on the line directly above the regex.\n');
|
|
563
|
+
process.stderr.write('`$((10#...))` base-10-forced shell arithmetic is banned outright in\n');
|
|
564
|
+
process.stderr.write('gsd-core/workflows/**/*.md and gsd-core/references/**/*.md — sanction with\n');
|
|
565
|
+
process.stderr.write('`<!-- phase-id-owner: <reason> -->` on the line directly above.\n');
|
|
566
|
+
process.stderr.write('The single-optional-dotted-segment phase regex `[0-9]+(\\.[0-9]+)?` (or its \\d\n');
|
|
567
|
+
process.stderr.write('near-variant) is banned outright in gsd-core/workflows/**/*.md,\n');
|
|
568
|
+
process.stderr.write('gsd-core/references/**/*.md, and agents/**/*.md — widen it to `*` (unbounded\n');
|
|
569
|
+
process.stderr.write('segments) or sanction with `<!-- phase-id-owner: <reason> -->`.\n');
|
|
570
|
+
process.stderr.write('A `.replace(\'{slug}\', ... || \'phase\')` fallback is banned outright (#4126) —\n');
|
|
571
|
+
process.stderr.write('use `renderPhaseBranchName(` or sanction with\n');
|
|
572
|
+
process.stderr.write('`// phase-id-owner: <reason>` on the line directly above:\n');
|
|
259
573
|
for (const d of violations) {
|
|
260
574
|
process.stderr.write(` [${d.kind}] ${d.file}:${d.line} ${d.found}\n`);
|
|
261
575
|
}
|
|
@@ -267,9 +581,20 @@ if (require.main === module) main();
|
|
|
267
581
|
module.exports = {
|
|
268
582
|
findPhaseIdRegexDrift,
|
|
269
583
|
findBracketGrammarDrift,
|
|
584
|
+
findNameValidityDrift,
|
|
585
|
+
findBranchSlugFallbackDrift,
|
|
586
|
+
findShellPhaseArithDrift,
|
|
587
|
+
findSingleSegmentPhaseRegexDrift,
|
|
588
|
+
scanMarkdownShellArith,
|
|
589
|
+
scanMarkdownSingleSegmentPhaseRegex,
|
|
270
590
|
scanRepo,
|
|
591
|
+
scanAll,
|
|
271
592
|
countSelectorBaselines,
|
|
272
593
|
scanSelectorBaselines,
|
|
273
594
|
TOKEN_DRIFT_RE,
|
|
274
595
|
BRACKET_CODE_DRIFT_RE,
|
|
596
|
+
NAME_VALIDITY_DRIFT_RE,
|
|
597
|
+
BRANCH_SLUG_FALLBACK_DRIFT_RE,
|
|
598
|
+
SHELL_PHASE_ARITH_DRIFT_RE,
|
|
599
|
+
SINGLE_SEGMENT_PHASE_DRIFT_RE,
|
|
275
600
|
};
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
* explanations" describes the gap rather than closing it. Coverage therefore
|
|
23
23
|
* requires a narration-class token as well — see `NARRATION_CLASS_RE`.
|
|
24
24
|
*
|
|
25
|
-
* A workflow FRAGMENT (`<workflow>/<modes|steps|templates>/<name>.md`, the
|
|
26
|
-
* the #1671 fragment epic extracts
|
|
25
|
+
* A workflow FRAGMENT (`<workflow>/<modes|steps|templates|detail>/<name>.md`, the
|
|
26
|
+
* shape the #1671 fragment epic extracts — `detail/` is the fourth such
|
|
27
|
+
* subdirectory kind, added by the #4403/ADR-4139 spine+detail split) additionally
|
|
28
|
+
* passes when its parent workflow
|
|
27
29
|
* names that exact fragment path and is itself covered — see
|
|
28
30
|
* `inheritsParentCoverage`, which proves the inheritance per file instead of
|
|
29
31
|
* granting it to a directory.
|
|
@@ -124,7 +126,11 @@ const EXACT_INLINE_DIRECTIVE_WORKFLOWS = new Set([
|
|
|
124
126
|
// failure for a quiet assumption; as it stands, extracting a fragment-of-a-
|
|
125
127
|
// fragment without pinning it turns the lint RED, which is the correct answer
|
|
126
128
|
// and names the file to fix.
|
|
127
|
-
|
|
129
|
+
// `detail` (ADR-4139 §6 Decision 6 / #4403) is the fourth fragment-directory kind,
|
|
130
|
+
// alongside modes/steps/templates from #1671: a spine's `detail/<part>.md` is
|
|
131
|
+
// reached the same way -- a `read and execute` stub in the top-level parent -- so
|
|
132
|
+
// it inherits coverage through the exact same mechanism, not a parallel one.
|
|
133
|
+
const FRAGMENT_DIRS = new Set(['modes', 'steps', 'templates', 'detail']);
|
|
128
134
|
const DIRECTIVE_ACTION_RE = /\b(?:apply|present|render|respond|translate|use|write|must|should)\b/i;
|
|
129
135
|
const USER_OUTPUT_RE = /\b(?:explanations?|language|narration|outputs?|prompts?|prose|questions?|templates?|user-facing)\b/i;
|
|
130
136
|
// The defect #2529 reports is NARRATION, not the question/answer surface: a
|
|
@@ -114,7 +114,7 @@ function toPosix(p) {
|
|
|
114
114
|
function isGeneratedOutput(relPath) {
|
|
115
115
|
const posixRel = toPosix(relPath);
|
|
116
116
|
return GENERATED_OUTPUT_PREFIXES.some(
|
|
117
|
-
(prefix) => posixRel === prefix || posixRel.startsWith(`${prefix}/`)
|
|
117
|
+
(prefix) => posixRel === prefix || posixRel.startsWith(`${prefix}/`) // allow-handrolled-containment: scan-exclusion membership test against a fixed generated-output prefix list, not a filesystem root-confinement gate
|
|
118
118
|
);
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -161,8 +161,24 @@ function compareFiles(relA, relB) {
|
|
|
161
161
|
* @param {string} spec
|
|
162
162
|
* @returns {string}
|
|
163
163
|
*/
|
|
164
|
+
const PIN_OPERATOR_RE = /^(\^|~|>=|<=|>|<|=)?/;
|
|
165
|
+
|
|
164
166
|
function stripRangeOperator(spec) {
|
|
165
|
-
return String(spec || '').trim().replace(
|
|
167
|
+
return String(spec || '').trim().replace(PIN_OPERATOR_RE, '').trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The leading range-operator token (if any) a package.json dependency spec
|
|
172
|
+
* was written with — the inverse half of stripRangeOperator, needed by
|
|
173
|
+
* `fixRow` to rebuild a pin (`<same operator>` + `<new version>`) that
|
|
174
|
+
* preserves the author's original range style instead of collapsing every
|
|
175
|
+
* pin to an exact version.
|
|
176
|
+
* @param {string} spec
|
|
177
|
+
* @returns {string} the operator (e.g. "^", "~", ">="), or "" for an exact pin
|
|
178
|
+
*/
|
|
179
|
+
function pinOperatorPrefix(spec) {
|
|
180
|
+
const m = String(spec || '').trim().match(PIN_OPERATOR_RE);
|
|
181
|
+
return (m && m[1]) || '';
|
|
166
182
|
}
|
|
167
183
|
|
|
168
184
|
/**
|
|
@@ -211,12 +227,40 @@ function checkHandAuthoredTwin(row) {
|
|
|
211
227
|
return findings;
|
|
212
228
|
}
|
|
213
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Read a row's pin state: the package.json devDependencies spec for
|
|
232
|
+
* `row.name` and, if `node_modules/<row.name>/package.json` exists, its
|
|
233
|
+
* installed version. Shared by checkRow (compares) and fixRow (rewrites) so
|
|
234
|
+
* the two can never silently diverge on how a pin is read.
|
|
235
|
+
* @param {VendoredPackage} row
|
|
236
|
+
* @param {string} [pkgRoot] Override for testing -- defaults to the real repo ROOT. Lets a
|
|
237
|
+
* test point fixRow's pin-rewrite at an isolated temp package.json instead of writing the
|
|
238
|
+
* real, shared one, which other concurrently-running node --test files read at module
|
|
239
|
+
* top-level (the same race class already fixed for the vendored .cjs copy).
|
|
240
|
+
* @returns {{pinnedSpec: string | undefined, installedVersion: string | undefined}}
|
|
241
|
+
*/
|
|
242
|
+
function readPinState(row, pkgRoot = ROOT) {
|
|
243
|
+
const pkgPath = path.join(pkgRoot, 'package.json');
|
|
244
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
245
|
+
const pinnedSpec = pkg.devDependencies && pkg.devDependencies[row.name];
|
|
246
|
+
const installedPkgPath = path.join(pkgRoot, 'node_modules', row.name, 'package.json');
|
|
247
|
+
let installedVersion;
|
|
248
|
+
if (fs.existsSync(installedPkgPath)) {
|
|
249
|
+
installedVersion = JSON.parse(fs.readFileSync(installedPkgPath, 'utf8')).version;
|
|
250
|
+
}
|
|
251
|
+
return { pinnedSpec, installedVersion };
|
|
252
|
+
}
|
|
253
|
+
|
|
214
254
|
/**
|
|
215
255
|
* Run all applicable freshness checks for one vendored package row.
|
|
216
256
|
* @param {VendoredPackage} row
|
|
257
|
+
* @param {string} [pkgRoot] Override for testing -- defaults to the real repo ROOT. Lets a
|
|
258
|
+
* test point fixRow's pin-rewrite at an isolated temp package.json instead of writing the
|
|
259
|
+
* real, shared one, which other concurrently-running node --test files read at module
|
|
260
|
+
* top-level (the same race class already fixed for the vendored .cjs copy).
|
|
217
261
|
* @returns {string[]} findings (empty when the row is fresh)
|
|
218
262
|
*/
|
|
219
|
-
function checkRow(row) {
|
|
263
|
+
function checkRow(row, pkgRoot = ROOT) {
|
|
220
264
|
const findings = [];
|
|
221
265
|
|
|
222
266
|
const cjsDrift = compareFiles(row.vendoredCjs, row.upstreamCjs);
|
|
@@ -235,31 +279,96 @@ function checkRow(row) {
|
|
|
235
279
|
findings.push(...checkHandAuthoredTwin(row));
|
|
236
280
|
}
|
|
237
281
|
|
|
238
|
-
const
|
|
239
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
240
|
-
const pinnedSpec = pkg.devDependencies && pkg.devDependencies[row.name];
|
|
282
|
+
const { pinnedSpec, installedVersion } = readPinState(row, pkgRoot);
|
|
241
283
|
if (!pinnedSpec) {
|
|
242
284
|
findings.push(`package.json devDependencies.${row.name} is missing`);
|
|
285
|
+
} else if (installedVersion === undefined) {
|
|
286
|
+
findings.push(`node_modules/${row.name}/package.json does not exist (run npm install)`);
|
|
243
287
|
} else {
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
246
|
-
findings.push(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (pinned !== installed.version) {
|
|
251
|
-
findings.push(
|
|
252
|
-
`package.json devDependencies.${row.name} ("${pinnedSpec}" -> "${pinned}") != `
|
|
253
|
-
+ `node_modules/${row.name}/package.json version ("${installed.version}")`,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
288
|
+
const pinned = stripRangeOperator(pinnedSpec);
|
|
289
|
+
if (pinned !== installedVersion) {
|
|
290
|
+
findings.push(
|
|
291
|
+
`package.json devDependencies.${row.name} ("${pinnedSpec}" -> "${pinned}") != `
|
|
292
|
+
+ `node_modules/${row.name}/package.json version ("${installedVersion}")`,
|
|
293
|
+
);
|
|
256
294
|
}
|
|
257
295
|
}
|
|
258
296
|
|
|
259
297
|
return findings;
|
|
260
298
|
}
|
|
261
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Mechanically resolve a vendored package's byte/pin drift: copy the
|
|
302
|
+
* upstream .cjs (and, for `upstream-verbatim` rows, the .d.cts twins) over
|
|
303
|
+
* the vendored copy, and bump the package.json pin to the installed
|
|
304
|
+
* version, preserving the original range-operator prefix. Then re-runs
|
|
305
|
+
* `checkRow` and returns whatever findings remain.
|
|
306
|
+
*
|
|
307
|
+
* This NEVER hand-edits a `hand-authored` twin (e.g. js-yaml.d.cts) — that
|
|
308
|
+
* file encodes a human's deliberate judgment about which exports are safe
|
|
309
|
+
* to expose, and only a human can tell whether a declared-export-missing
|
|
310
|
+
* finding is mechanical drift or a real upstream API break. If one remains
|
|
311
|
+
* after this runs, that is by design: the caller must not treat it as
|
|
312
|
+
* fixed.
|
|
313
|
+
* @param {VendoredPackage} row
|
|
314
|
+
* @param {string} [pkgRoot] Override for testing -- defaults to the real repo ROOT. Lets a
|
|
315
|
+
* test point fixRow's pin-rewrite at an isolated temp package.json instead of writing the
|
|
316
|
+
* real, shared one, which other concurrently-running node --test files read at module
|
|
317
|
+
* top-level (the same race class already fixed for the vendored .cjs copy).
|
|
318
|
+
* @returns {string[]} findings remaining after the fix (empty when fully resolved)
|
|
319
|
+
*/
|
|
320
|
+
function fixRow(row, pkgRoot = ROOT) {
|
|
321
|
+
fs.copyFileSync(resolvePath(row.upstreamCjs), resolvePath(row.vendoredCjs));
|
|
322
|
+
|
|
323
|
+
if (row.twinKind === 'upstream-verbatim' && row.upstreamDts) {
|
|
324
|
+
if (row.vendoredDts) fs.copyFileSync(resolvePath(row.upstreamDts), resolvePath(row.vendoredDts));
|
|
325
|
+
if (row.srcTwin) fs.copyFileSync(resolvePath(row.upstreamDts), resolvePath(row.srcTwin));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const { pinnedSpec, installedVersion } = readPinState(row, pkgRoot);
|
|
329
|
+
if (pinnedSpec && installedVersion !== undefined) {
|
|
330
|
+
const newPin = `${pinOperatorPrefix(pinnedSpec)}${installedVersion}`;
|
|
331
|
+
if (newPin !== pinnedSpec) {
|
|
332
|
+
const pkgPath = path.join(pkgRoot, 'package.json');
|
|
333
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
334
|
+
pkg.devDependencies[row.name] = newPin;
|
|
335
|
+
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return checkRow(row, pkgRoot);
|
|
340
|
+
}
|
|
341
|
+
|
|
262
342
|
function main() {
|
|
343
|
+
if (process.argv.includes('--fix')) {
|
|
344
|
+
/** @type {Record<string, string[]>} */
|
|
345
|
+
const remaining = {};
|
|
346
|
+
for (const row of VENDORED) {
|
|
347
|
+
const findings = fixRow(row);
|
|
348
|
+
if (findings.length > 0) remaining[row.name] = findings;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const names = VENDORED.map((row) => row.name).join(', ');
|
|
352
|
+
|
|
353
|
+
if (Object.keys(remaining).length === 0) {
|
|
354
|
+
process.stdout.write(
|
|
355
|
+
`ok lint-vendored-deps --fix: gsd-core/bin/lib/vendor/{${names}} refreshed and now match node_modules and their pinned versions\n`,
|
|
356
|
+
);
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const detail = Object.entries(remaining)
|
|
361
|
+
.map(([name, findings]) => ` ${name}:\n${findings.map((f) => ` ${f}`).join('\n')}`)
|
|
362
|
+
.join('\n');
|
|
363
|
+
throw new ExitError(
|
|
364
|
+
1,
|
|
365
|
+
`lint-vendored-deps --fix: mechanical drift refreshed, but the following row(s)\n`
|
|
366
|
+
+ 'still have findings that --fix cannot resolve automatically — these need a\n'
|
|
367
|
+
+ 'human, not just a re-run of --fix:\n'
|
|
368
|
+
+ detail,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
263
372
|
const findings = [];
|
|
264
373
|
for (const row of VENDORED) {
|
|
265
374
|
findings.push(...checkRow(row));
|
|
@@ -288,9 +397,11 @@ if (require.main === module) runMain(main);
|
|
|
288
397
|
module.exports = {
|
|
289
398
|
compareFiles,
|
|
290
399
|
stripRangeOperator,
|
|
400
|
+
pinOperatorPrefix,
|
|
291
401
|
VENDORED,
|
|
292
402
|
buildRefreshCommand,
|
|
293
403
|
checkRow,
|
|
404
|
+
fixRow,
|
|
294
405
|
declaredValueExports,
|
|
295
406
|
checkHandAuthoredTwin,
|
|
296
407
|
resolvePath,
|