dflow-sdd-ddd 0.13.0 → 0.15.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/CHANGELOG.md +824 -1
- package/CONTRIBUTING.md +16 -10
- package/README.en.md +156 -200
- package/README.md +89 -144
- package/TEMPLATE-COVERAGE.md +15 -8
- package/TEMPLATE-LANGUAGE-GLOSSARY.md +15 -1
- package/bin/dflow.js +36 -4
- package/docs/commands.en.md +110 -0
- package/docs/commands.md +101 -0
- package/docs/doctor-uncertainty.en.md +212 -0
- package/docs/doctor-uncertainty.md +212 -0
- package/docs/evaluating-dflow.en.md +29 -11
- package/docs/evaluating-dflow.md +8 -6
- package/docs/npm-publish-checklist.md +3 -1
- package/docs/release-versioning-policy.md +8 -2
- package/docs/upgrading.en.md +196 -0
- package/docs/upgrading.md +197 -0
- package/docs/using-with-claude-code.en.md +25 -10
- package/docs/using-with-claude-code.md +20 -7
- package/docs/using-with-codex.en.md +18 -6
- package/docs/using-with-codex.md +16 -5
- package/docs/using-with-github-copilot.en.md +25 -10
- package/docs/using-with-github-copilot.md +21 -8
- package/lib/doc-shapes.json +997 -0
- package/lib/doctor-checks.js +2654 -0
- package/lib/init.js +3583 -107
- package/lib/render-diagrams.js +1474 -0
- package/lib/render.js +865 -49
- package/package.json +2 -2
- package/templates/brownfield/references/drift-verification.md +4 -0
- package/templates/brownfield/references/finish-feature-flow.md +635 -88
- package/templates/brownfield/references/finish-feature-follow-up.md +60 -0
- package/templates/brownfield/references/finish-feature-minimal-host.md +406 -0
- package/templates/brownfield/references/finish-feature-post-hoc-hotfix.md +95 -0
- package/templates/brownfield/references/git-integration.md +160 -15
- package/templates/brownfield/references/init-project-flow.md +26 -4
- package/templates/brownfield/references/modify-existing-flow.md +412 -87
- package/templates/brownfield/references/modify-existing-follow-up.md +121 -0
- package/templates/brownfield/references/modify-existing-post-hoc-hotfix.md +82 -0
- package/templates/brownfield/references/new-feature-flow.md +61 -6
- package/templates/brownfield/references/new-phase-flow.md +57 -7
- package/templates/brownfield/references/pr-review-checklist.md +303 -10
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +158 -34
- package/templates/brownfield/scaffolding/CLAUDE-md-snippet.md +1 -1
- package/templates/brownfield/scaffolding/Git-principles-gitflow.md +75 -6
- package/templates/brownfield/scaffolding/Git-principles-trunk.md +82 -8
- package/templates/brownfield/scaffolding/_conventions.md +50 -28
- package/templates/brownfield/scaffolding/_overview.md +1 -0
- package/templates/brownfield/templates/_index.md +151 -7
- package/templates/brownfield/templates/analysis.md +79 -0
- package/templates/brownfield/templates/behavior.md +1 -0
- package/templates/brownfield/templates/context-definition.md +1 -0
- package/templates/brownfield/templates/context-map.md +2 -1
- package/templates/brownfield/templates/glossary.md +1 -0
- package/templates/brownfield/templates/lightweight-spec.md +154 -11
- package/templates/brownfield/templates/models.md +1 -0
- package/templates/brownfield/templates/phase-spec.md +9 -1
- package/templates/brownfield/templates/rules.md +1 -0
- package/templates/brownfield/templates/tech-debt.md +1 -0
- package/templates/common/references/ddd-modeling-guide.md +33 -16
- package/templates/{greenfield → common}/references/dflow-feedback-flow.md +2 -1
- package/templates/common/references/flow-rationale-registry.md +130 -0
- package/templates/common/skill/SKILL.md +13 -11
- package/templates/greenfield/references/drift-verification.md +4 -0
- package/templates/greenfield/references/finish-feature-flow.md +625 -89
- package/templates/greenfield/references/finish-feature-follow-up.md +60 -0
- package/templates/greenfield/references/finish-feature-minimal-host.md +363 -0
- package/templates/greenfield/references/finish-feature-post-hoc-hotfix.md +95 -0
- package/templates/greenfield/references/git-integration.md +148 -15
- package/templates/greenfield/references/init-project-flow.md +28 -8
- package/templates/greenfield/references/modify-existing-flow.md +378 -85
- package/templates/greenfield/references/modify-existing-follow-up.md +103 -0
- package/templates/greenfield/references/modify-existing-post-hoc-hotfix.md +82 -0
- package/templates/greenfield/references/new-feature-flow.md +67 -4
- package/templates/greenfield/references/new-phase-flow.md +56 -7
- package/templates/greenfield/references/pr-review-checklist.md +287 -8
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +153 -32
- package/templates/greenfield/scaffolding/CLAUDE-md-snippet.md +5 -2
- package/templates/greenfield/scaffolding/Git-principles-gitflow.md +74 -6
- package/templates/greenfield/scaffolding/Git-principles-trunk.md +88 -12
- package/templates/greenfield/scaffolding/_conventions.md +50 -28
- package/templates/greenfield/scaffolding/_overview.md +6 -2
- package/templates/greenfield/templates/_index.md +137 -7
- package/templates/greenfield/templates/aggregate-design.md +1 -0
- package/templates/greenfield/templates/analysis.md +79 -0
- package/templates/greenfield/templates/behavior.md +1 -0
- package/templates/greenfield/templates/context-definition.md +1 -0
- package/templates/greenfield/templates/context-map.md +2 -1
- package/templates/greenfield/templates/events.md +4 -1
- package/templates/greenfield/templates/glossary.md +1 -0
- package/templates/greenfield/templates/lightweight-spec.md +154 -11
- package/templates/greenfield/templates/models.md +1 -0
- package/templates/greenfield/templates/phase-spec.md +9 -1
- package/templates/greenfield/templates/rules.md +1 -0
- package/templates/greenfield/templates/tech-debt.md +1 -0
- package/templates/brownfield/references/dflow-feedback-flow.md +0 -251
package/lib/init.js
CHANGED
|
@@ -4,6 +4,7 @@ const readline = require('node:readline');
|
|
|
4
4
|
const { TextDecoder } = require('node:util');
|
|
5
5
|
|
|
6
6
|
const pkg = require('../package.json');
|
|
7
|
+
const doctorChecks = require('./doctor-checks');
|
|
7
8
|
|
|
8
9
|
const MIN_NODE_VERSION = '22.0.0';
|
|
9
10
|
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
@@ -23,6 +24,33 @@ const CODEX_TRIGGER_SECTION_END = '<!-- dflow-generated: codex-command-triggers
|
|
|
23
24
|
// freeze the pre-marker shim template for back-compat.
|
|
24
25
|
const AGENT_SHIM_SECTION_START = '<!-- dflow-generated: agent-shim START -->';
|
|
25
26
|
const AGENT_SHIM_SECTION_END = '<!-- dflow-generated: agent-shim END -->';
|
|
27
|
+
// PROPOSAL-058: the packaged guide templates wrap their Dflow-canonical body in
|
|
28
|
+
// this pair; configure-agents refreshes the region in place on upgrade and never
|
|
29
|
+
// touches content outside it ("## Project Context" and anything else the user
|
|
30
|
+
// keeps there).
|
|
31
|
+
const GUIDE_CANONICAL_SECTION_START = '<!-- dflow-generated: guide-canonical START -->';
|
|
32
|
+
const GUIDE_CANONICAL_SECTION_END = '<!-- dflow-generated: guide-canonical END -->';
|
|
33
|
+
const AI_AGENT_GUIDE_DEST = 'dflow/specs/shared/AI-AGENT-GUIDE.md';
|
|
34
|
+
// PROPOSAL-090 (route B3): the Git principles starter is user-owned — projects
|
|
35
|
+
// write their own AI-collaboration policy and CI/CD sections into it — but
|
|
36
|
+
// sections 1-5 are Dflow-canonical and three trunk flow files read them, so a
|
|
37
|
+
// starter frozen at its init version leaves those flows pointing at rules the
|
|
38
|
+
// project never received. Measured 2026-08-18 on a project whose upgrade
|
|
39
|
+
// process was otherwise flawless: 65 lines behind, and not polish.
|
|
40
|
+
// ⚠ A SEPARATE marker pair from the guide's, deliberately: the guide-canonical
|
|
41
|
+
// START string is also used as a section-boundary terminator in
|
|
42
|
+
// `projectContextSectionBounds`, so sharing the token would tie two unrelated
|
|
43
|
+
// mechanisms to one string.
|
|
44
|
+
const GIT_PRINCIPLES_CANONICAL_START = '<!-- dflow-generated: git-principles-canonical START -->';
|
|
45
|
+
const GIT_PRINCIPLES_CANONICAL_END = '<!-- dflow-generated: git-principles-canonical END -->';
|
|
46
|
+
// The two headings that bound the canonical region. Verified stable across
|
|
47
|
+
// v0.9.0..HEAD in all four starters (2 editions x 2 policies), which is what
|
|
48
|
+
// makes marker adoption on a pre-marker file decidable rather than a guess.
|
|
49
|
+
// ⚠ Anchor on the exact heading text, never on "the next `## `": the gitflow
|
|
50
|
+
// starters carry a `## [1.2.3] — {YYYY-MM-DD}` CHANGELOG example inside a
|
|
51
|
+
// fenced block.
|
|
52
|
+
const GIT_PRINCIPLES_CANONICAL_FIRST_HEADING = '## 1. Branch Structure';
|
|
53
|
+
const GIT_PRINCIPLES_CANONICAL_AFTER_HEADING = '## 6. AI Collaboration Rules (Project Policy)';
|
|
26
54
|
const WORKFLOW_BUNDLE_DEST = 'dflow/specs/shared/dflow-workflows';
|
|
27
55
|
const WORKFLOW_BUNDLE_MANIFEST_PATH = `${WORKFLOW_BUNDLE_DEST}/.dflow-bundle-manifest.json`;
|
|
28
56
|
const COMMON_SKILL_SOURCE_REL = 'common/skill/SKILL.md';
|
|
@@ -32,7 +60,14 @@ const COMMON_SKILL_SOURCE_REL = 'common/skill/SKILL.md';
|
|
|
32
60
|
// and configure-agents stale-removal would then DELETE the already-installed
|
|
33
61
|
// copy from the user's project (it diffs as "retired"). PROPOSAL-064 fresh-gate
|
|
34
62
|
// finding. Guarded before any stale cleanup / manifest write.
|
|
35
|
-
|
|
63
|
+
// Frozen because it is exported: an unfrozen array could be emptied by any
|
|
64
|
+
// consumer (`REQUIRED_COMMON_BUNDLE_FILES.length = 0`), silently turning the
|
|
65
|
+
// completeness guard into a no-op for the rest of the process.
|
|
66
|
+
const REQUIRED_COMMON_BUNDLE_FILES = Object.freeze([
|
|
67
|
+
'references/ddd-modeling-guide.md',
|
|
68
|
+
'references/dflow-feedback-flow.md',
|
|
69
|
+
'references/flow-rationale-registry.md'
|
|
70
|
+
]);
|
|
36
71
|
const EXPECTED_COMMAND_IDS = [
|
|
37
72
|
'new-feature',
|
|
38
73
|
'modify-existing',
|
|
@@ -197,6 +232,31 @@ const DEFERRED_COMMON = [
|
|
|
197
232
|
relativePath: 'dflow/specs/domain/{context}/rules.md',
|
|
198
233
|
reason: 'Needs a real bounded context.'
|
|
199
234
|
},
|
|
235
|
+
{
|
|
236
|
+
relativePath: 'dflow/specs/domain/{context}/analysis.md',
|
|
237
|
+
reason: 'Needs a real bounded context.'
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
relativePath: 'dflow/specs/domain/analysis.md',
|
|
241
|
+
reason: 'Created the first time a session records a cross-context flow, role reach, or other knowledge no single context owns.'
|
|
242
|
+
}
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
// ⚠ EDITION-SPECIFIC DEFERRALS, and the reason this list exists is that the
|
|
246
|
+
// other one is named COMMON. The ADR row used to sit in DEFERRED_COMMON while
|
|
247
|
+
// `dflow/specs/architecture/` is a greenfield-only tree — brownfield records
|
|
248
|
+
// architecture decisions under `dflow/specs/migration/` and never creates
|
|
249
|
+
// `architecture/` at all. So every brownfield `dflow init` printed a promise to
|
|
250
|
+
// create a directory the CLI will never create (`debt20-tut-y5`, confirmed by
|
|
251
|
+
// `debt20-tut-y6`). `buildDeferredItems` only ever ADDED to the common list, so
|
|
252
|
+
// nothing could subtract a row that did not apply — the fix is to stop putting
|
|
253
|
+
// edition-specific rows in the shared list rather than to add a subtraction
|
|
254
|
+
// step. Anything that is true for one edition only belongs here.
|
|
255
|
+
const DEFERRED_GREENFIELD_ONLY = [
|
|
256
|
+
{
|
|
257
|
+
relativePath: 'dflow/specs/domain/{context}/events.md',
|
|
258
|
+
reason: 'Greenfield only, but still needs a real bounded context.'
|
|
259
|
+
},
|
|
200
260
|
{
|
|
201
261
|
relativePath: 'dflow/specs/architecture/decisions/ADR-*.md',
|
|
202
262
|
reason: 'ADRs are created when a real architecture decision exists.'
|
|
@@ -367,14 +427,52 @@ async function runConfigureAgents(options = {}) {
|
|
|
367
427
|
}
|
|
368
428
|
}
|
|
369
429
|
|
|
370
|
-
|
|
430
|
+
// PROPOSAL-058: adoption offers are decided by planning the run twice. The
|
|
431
|
+
// first plan flags what is offer-able (a recognizable pre-marker guide, a
|
|
432
|
+
// guide-referencing agent file Dflow does not manage); an interactive run
|
|
433
|
+
// then asks, and only a granted consent triggers a re-plan. A non-TTY run
|
|
434
|
+
// never asks and never consumes a stdin slot (the PROPOSAL-074 contract:
|
|
435
|
+
// existing piped answer sequences must run unchanged), so it keeps the
|
|
436
|
+
// skip + warn behavior. Deriving the offers from the plan itself keeps the
|
|
437
|
+
// question conditions and the plan branches from ever drifting apart.
|
|
438
|
+
const interactive = Boolean(stdin.isTTY && stdout.isTTY);
|
|
439
|
+
let adoptGuideMarkers = false;
|
|
440
|
+
let adoptGitPrinciplesMarkers = false;
|
|
441
|
+
const adoptShimAgents = [];
|
|
442
|
+
const buildPlan = () => buildConfigureAgentsPlan(cwd, {
|
|
371
443
|
...projectContext,
|
|
372
444
|
aiAgents,
|
|
373
445
|
commandAdapters: Boolean(options.commandAdapters),
|
|
374
446
|
skills: Boolean(options.skills),
|
|
375
|
-
skillAgents
|
|
447
|
+
skillAgents,
|
|
448
|
+
adoptGuideMarkers,
|
|
449
|
+
adoptGitPrinciplesMarkers,
|
|
450
|
+
adoptShimAgents
|
|
376
451
|
});
|
|
377
452
|
|
|
453
|
+
let plan = await buildPlan();
|
|
454
|
+
|
|
455
|
+
if (interactive) {
|
|
456
|
+
const offersGuide = plan.items.some((item) => item.offerGuideAdoption);
|
|
457
|
+
const shimOffers = dedupe(
|
|
458
|
+
plan.items.filter((item) => item.offerShimAdoption).map((item) => item.offerShimAdoption)
|
|
459
|
+
);
|
|
460
|
+
if (offersGuide) {
|
|
461
|
+
adoptGuideMarkers = await askGuideMarkerAdoption(rl, stdout);
|
|
462
|
+
}
|
|
463
|
+
if (plan.items.some((item) => item.offerGitPrinciplesAdoption)) {
|
|
464
|
+
adoptGitPrinciplesMarkers = await askGitPrinciplesMarkerAdoption(rl, stdout);
|
|
465
|
+
}
|
|
466
|
+
for (const agent of shimOffers) {
|
|
467
|
+
if (await askShimBlockAdoption(rl, stdout, agent)) {
|
|
468
|
+
adoptShimAgents.push(agent);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (adoptGuideMarkers || adoptGitPrinciplesMarkers || adoptShimAgents.length > 0) {
|
|
472
|
+
plan = await buildPlan();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
378
476
|
const warnings = plan.warnings || [];
|
|
379
477
|
renderPreview(stdout, plan, warnings);
|
|
380
478
|
const confirmed = await askConfirmation(rl, 'Create these files? (y/N) ');
|
|
@@ -752,18 +850,19 @@ async function inferProjectContext(cwd, rl, stdout, stderr) {
|
|
|
752
850
|
};
|
|
753
851
|
}
|
|
754
852
|
|
|
853
|
+
// The machine-readable line patterns and value parse live in lib/doctor-checks.js
|
|
854
|
+
// so the doctor "machine format" findings and this inference can never drift
|
|
855
|
+
// apart (PROPOSAL-058).
|
|
755
856
|
async function inferGitPolicy(cwd) {
|
|
756
857
|
const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
|
|
757
858
|
const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
|
|
758
|
-
|
|
759
|
-
return match ? match[1] : null;
|
|
859
|
+
return doctorChecks.parseContextLine(content, doctorChecks.GIT_POLICY_LINE_RE);
|
|
760
860
|
}
|
|
761
861
|
|
|
762
862
|
async function inferAiCommitMarker(cwd) {
|
|
763
863
|
const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
|
|
764
864
|
const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
|
|
765
|
-
|
|
766
|
-
return match ? match[1] : null;
|
|
865
|
+
return doctorChecks.parseContextLine(content, doctorChecks.AI_COMMIT_MARKER_LINE_RE);
|
|
767
866
|
}
|
|
768
867
|
|
|
769
868
|
async function inferExistingEdition(cwd) {
|
|
@@ -782,22 +881,43 @@ async function inferExistingEdition(cwd) {
|
|
|
782
881
|
async function inferProseLanguage(cwd) {
|
|
783
882
|
const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
|
|
784
883
|
const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
|
|
785
|
-
|
|
786
|
-
return match ? match[1] : 'unknown';
|
|
884
|
+
return doctorChecks.parseContextLine(content, doctorChecks.PROSE_LANGUAGE_LINE_RE) ?? 'unknown';
|
|
787
885
|
}
|
|
788
886
|
|
|
887
|
+
// PROPOSAL-076: the machine-readable record of the init Q2/Q3 answers is the
|
|
888
|
+
// guide's "## Project Context" table — init substitutes them into the
|
|
889
|
+
// `| Tech stack |` / `| Migration / legacy context |` rows there. (The
|
|
890
|
+
// pre-076 code looked for those rows in _overview.md, which never carried
|
|
891
|
+
// them in any packaged template, so inference always fell back.) Only the
|
|
892
|
+
// Project Context section is parsed: it is the contractual user region
|
|
893
|
+
// (PROPOSAL-058), so a same-name row anywhere else in the guide can never
|
|
894
|
+
// shadow it.
|
|
789
895
|
async function inferTechStackSummary(cwd) {
|
|
790
|
-
|
|
791
|
-
const content = await fs.readFile(overviewPath, 'utf8').catch(() => '');
|
|
792
|
-
const match = content.match(/\|\s*Tech stack\s*\|\s*([^|\n]+?)\s*\|/i);
|
|
793
|
-
return match ? match[1].trim() : 'unknown';
|
|
896
|
+
return (await inferGuideProjectContextValue(cwd, doctorChecks.TECH_STACK_ROW_RE)) ?? 'unknown';
|
|
794
897
|
}
|
|
795
898
|
|
|
796
899
|
async function inferMigrationContext(cwd) {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
900
|
+
return (await inferGuideProjectContextValue(cwd, doctorChecks.MIGRATION_CONTEXT_ROW_RE)) ?? 'none';
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function inferGuideProjectContextValue(cwd, re) {
|
|
904
|
+
const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
|
|
905
|
+
const content = await fs.readFile(guidePath, 'utf8').catch(() => '');
|
|
906
|
+
const slice = projectContextParseSlice(toLf(content));
|
|
907
|
+
return slice === null ? null : doctorChecks.parseContextLine(slice, re);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// The parseable slice of the "## Project Context" section: fenced examples
|
|
911
|
+
// inside the section are blanked so a decoy row in a fence can neither supply
|
|
912
|
+
// nor shadow a value (PROPOSAL-076 gate G2) — fence state is clean at the
|
|
913
|
+
// heading because the fence-aware bounds scan would not have matched a heading
|
|
914
|
+
// inside a fence. Inference and the doctor row check must both parse through
|
|
915
|
+
// this slice so they can never disagree.
|
|
916
|
+
function projectContextParseSlice(lfContent) {
|
|
917
|
+
const bounds = projectContextSectionBounds(lfContent);
|
|
918
|
+
if (!bounds) return null;
|
|
919
|
+
const section = bounds.lines.slice(bounds.start, bounds.end).join('\n');
|
|
920
|
+
return doctorChecks.blankFencedBlocks(section).join('\n');
|
|
801
921
|
}
|
|
802
922
|
|
|
803
923
|
async function askSelect(rl, stdout, stderr, config) {
|
|
@@ -1221,19 +1341,19 @@ async function buildConfigureAgentsPlan(cwd, answers) {
|
|
|
1221
1341
|
const items = [];
|
|
1222
1342
|
const warnings = [];
|
|
1223
1343
|
|
|
1224
|
-
let
|
|
1225
|
-
|
|
1226
|
-
items
|
|
1227
|
-
|
|
1228
|
-
source: `packaged:${answers.edition}/scaffolding/AI-AGENT-GUIDE.md`,
|
|
1229
|
-
notes: 'canonical AI agent guide',
|
|
1230
|
-
content
|
|
1231
|
-
});
|
|
1344
|
+
let packagedGuide = await readPackagedTemplate(answers.edition, 'scaffolding/AI-AGENT-GUIDE.md');
|
|
1345
|
+
packagedGuide = substitutePlaceholders(packagedGuide, substitution);
|
|
1346
|
+
await addCanonicalGuideItem(cwd, items, warnings, packagedGuide, answers);
|
|
1347
|
+
await addGitPrinciplesItem(cwd, items, warnings, answers);
|
|
1232
1348
|
|
|
1233
|
-
const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(
|
|
1349
|
+
const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(packagedGuide) : [];
|
|
1234
1350
|
|
|
1235
1351
|
for (const agent of answers.aiAgents) {
|
|
1236
|
-
await addAiAgentShim(cwd, items, agent, substitution, {
|
|
1352
|
+
await addAiAgentShim(cwd, items, agent, substitution, {
|
|
1353
|
+
commandRegistry,
|
|
1354
|
+
warnings,
|
|
1355
|
+
adoptShimAgents: answers.adoptShimAgents || []
|
|
1356
|
+
});
|
|
1237
1357
|
}
|
|
1238
1358
|
|
|
1239
1359
|
if (answers.commandAdapters) {
|
|
@@ -1260,6 +1380,15 @@ async function buildConfigureAgentsPlan(cwd, answers) {
|
|
|
1260
1380
|
await addWorkflowBundleItems(cwd, items, bundleWarnings, answers.edition);
|
|
1261
1381
|
warnings.push(...bundleWarnings);
|
|
1262
1382
|
|
|
1383
|
+
// Last plan item on purpose: the write phase runs in plan order and aborts on
|
|
1384
|
+
// the first failure, so the last-reconciled version line only advances when
|
|
1385
|
+
// everything this run re-projects was written. Guarded skips do not abort the
|
|
1386
|
+
// write phase, so the item additionally carries requiresFullApply — the write
|
|
1387
|
+
// phase drops it (with a warning) when any earlier planned change was skipped
|
|
1388
|
+
// unexpectedly (changed after preview, vanished target, unexpected existing
|
|
1389
|
+
// target).
|
|
1390
|
+
await addConventionsVersionReconcileItem(cwd, items);
|
|
1391
|
+
|
|
1263
1392
|
return {
|
|
1264
1393
|
items,
|
|
1265
1394
|
deferred: [],
|
|
@@ -1296,15 +1425,65 @@ async function finalizePlanItems(cwd, items) {
|
|
|
1296
1425
|
// source trees, so the merged dest path / manifest entry never collide or
|
|
1297
1426
|
// shadow each other. Pure (no I/O) so it is unit-testable on a synthetic list.
|
|
1298
1427
|
function assertNoBundleCollision(files) {
|
|
1428
|
+
// Keyed case-INSENSITIVELY. The merged set is projected onto the adopter's
|
|
1429
|
+
// filesystem, where Windows and default macOS treat `Foo.md` and `foo.md` as
|
|
1430
|
+
// one path: two source trees differing only in case would ship two logical
|
|
1431
|
+
// files that overwrite each other there, while an exact-match key reports no
|
|
1432
|
+
// collision at all. A case-sensitive dev checkout or CI can create that pair,
|
|
1433
|
+
// so the guard cannot rely on the authoring filesystem to prevent it.
|
|
1434
|
+
// Case-folded and NFC-normalised. ⚠ The guard's two halves have DIFFERENT
|
|
1435
|
+
// reach, and collapsing them into one sentence has produced a wrong comment
|
|
1436
|
+
// twice — once claiming adopters are protected everywhere, once claiming the
|
|
1437
|
+
// guard is silent everywhere. Both were generalised from a probe that only
|
|
1438
|
+
// covered one half. Measured on a case-insensitive (Windows) host:
|
|
1439
|
+
//
|
|
1440
|
+
// * CROSS-ROOT pair (templates/common/… vs templates/{edition}/…) — the two
|
|
1441
|
+
// files sit in DIFFERENT directories, so both spellings exist on any
|
|
1442
|
+
// filesystem. readdir yields a descriptor from each root and this guard
|
|
1443
|
+
// throws before anything is projected. Probe: common/x.md + greenfield/X.md
|
|
1444
|
+
// both present, guard fires. Real runtime protection, Windows included,
|
|
1445
|
+
// and the case this guard mainly exists for.
|
|
1446
|
+
// * SAME-TREE pair (two spellings in ONE directory) — reach depends on WHICH
|
|
1447
|
+
// axis differs, because the key folds two of them and filesystems do not
|
|
1448
|
+
// treat them alike:
|
|
1449
|
+
//
|
|
1450
|
+
// axis \ host | Linux (case-sens, norm-sens) | Windows (case-INsens, norm-SENS) | macOS default (both INsens)
|
|
1451
|
+
// case pair | coexists -> canary | collapses -> silent | collapses -> silent
|
|
1452
|
+
// NFD/NFC pair | coexists -> canary | COEXISTS -> guard fires | collapses -> silent
|
|
1453
|
+
//
|
|
1454
|
+
// Probes on Windows: `Foo.md` then `foo.md` leaves readdir count=1 (and
|
|
1455
|
+
// the FIRST name survives carrying the SECOND write's content); an
|
|
1456
|
+
// NFD/NFC pair leaves count=2 and this guard throws. NTFS is
|
|
1457
|
+
// case-insensitive but normalization-SENSITIVE — so the normalisation is
|
|
1458
|
+
// not defensive dressing, it is the one axis with runtime reach here.
|
|
1459
|
+
// ⚠ The macOS column is inference from documented APFS/HFS+ behaviour,
|
|
1460
|
+
// not measured; the other two columns are measured on this host.
|
|
1461
|
+
//
|
|
1462
|
+
// Guarding the source filenames themselves — OS-special forms such as a
|
|
1463
|
+
// Windows ADS `name.md:stream` — is a wider job, filed as
|
|
1464
|
+
// `bundle-name-validity` in planning/opt-in-backlog.md.
|
|
1465
|
+
//
|
|
1466
|
+
// The real key set is ASCII today, which is exactly why the normalisation has
|
|
1467
|
+
// to be written here rather than assumed.
|
|
1299
1468
|
const seenBy = new Map();
|
|
1300
1469
|
for (const f of files) {
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1470
|
+
const key = f.sourceRel.normalize('NFC').toLowerCase();
|
|
1471
|
+
const prior = seenBy.get(key);
|
|
1472
|
+
// Two spellings collide REGARDLESS of which tree they came from. Keying on
|
|
1473
|
+
// sourceRoot alone would miss the same hazard inside one tree — two files
|
|
1474
|
+
// in templates/common/ differing only in case are equally one path on the
|
|
1475
|
+
// adopter's filesystem, and would project two manifest entries onto it.
|
|
1476
|
+
if (prior && (prior.sourceRoot !== f.sourceRoot || prior.sourceRel !== f.sourceRel)) {
|
|
1477
|
+
const where = prior.sourceRoot === f.sourceRoot
|
|
1478
|
+
? `twice in templates/${f.sourceRoot}/ (as "${prior.sourceRel}" and "${f.sourceRel}")`
|
|
1479
|
+
: prior.sourceRel === f.sourceRel
|
|
1480
|
+
? `in both templates/${prior.sourceRoot}/ and templates/${f.sourceRoot}/`
|
|
1481
|
+
: `in both templates/${prior.sourceRoot}/ (as "${prior.sourceRel}") and templates/${f.sourceRoot}/ (as "${f.sourceRel}")`;
|
|
1303
1482
|
throw new InitError(
|
|
1304
|
-
`Internal error: workflow bundle file "${f.sourceRel}" exists
|
|
1483
|
+
`Internal error: workflow bundle file "${f.sourceRel}" exists ${where}. A bundle file name must be unique across the common and edition source trees — compared case-insensitively, because the projected destination lands on filesystems that treat "Foo.md" and "foo.md" as one path.`
|
|
1305
1484
|
);
|
|
1306
1485
|
}
|
|
1307
|
-
seenBy.set(
|
|
1486
|
+
seenBy.set(key, f);
|
|
1308
1487
|
}
|
|
1309
1488
|
}
|
|
1310
1489
|
|
|
@@ -1313,10 +1492,14 @@ function assertNoBundleCollision(files) {
|
|
|
1313
1492
|
// (templates/) — the common tree must NOT mask a broken edition (a vanished
|
|
1314
1493
|
// templates/{edition}/references/ would otherwise be hidden by common's
|
|
1315
1494
|
// non-empty references/). Enforced in the scanner (not only the projector) so
|
|
1316
|
-
// BOTH callers are covered: the projector hard-fails, and doctor
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1495
|
+
// BOTH callers are covered: the projector hard-fails, and doctor skips the orphan
|
|
1496
|
+
// scan rather than mis-reporting every projected flow file as orphaned.
|
|
1497
|
+
// ⚠ That skip is no longer SILENT, and this sentence used to read as permission for
|
|
1498
|
+
// it to be: doctor now also emits a `warn` naming the broken install
|
|
1499
|
+
// (see checkWorkflowBundleSourceAndOrphans). A maintainer who restores a bare
|
|
1500
|
+
// `catch { return; }` on the strength of "degrades gracefully" reintroduces the
|
|
1501
|
+
// exact false-clean this repo spent four review rounds closing. Pure (no I/O) so it
|
|
1502
|
+
// is unit-testable on a synthetic list.
|
|
1320
1503
|
function assertEditionBundleComplete(files, edition) {
|
|
1321
1504
|
const hasEditionRefs = files.some((f) => f.sourceRoot === edition && f.dir === 'references');
|
|
1322
1505
|
const hasEditionTemplates = files.some((f) => f.sourceRoot === edition && f.dir === 'templates');
|
|
@@ -1353,32 +1536,38 @@ function assertCommonBundleComplete(files) {
|
|
|
1353
1536
|
// at the same dflow/.../references/<name> path in every edition. The scanner
|
|
1354
1537
|
// validates the merged set (collision + complete-edition guards) before
|
|
1355
1538
|
// returning, so both callers (projector, doctor) get a trustworthy list.
|
|
1356
|
-
async function
|
|
1357
|
-
const bundleDirs = ['references', 'templates'];
|
|
1358
|
-
const sourceRoots = ['common', edition];
|
|
1539
|
+
async function scanBundleSourceRoot(sourceRoot) {
|
|
1359
1540
|
const files = [];
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
if (error.code === 'ENOENT') {
|
|
1369
|
-
continue;
|
|
1370
|
-
}
|
|
1371
|
-
throw error;
|
|
1541
|
+
for (const dir of ['references', 'templates']) {
|
|
1542
|
+
const sourceDir = path.join(TEMPLATE_ROOT, sourceRoot, dir);
|
|
1543
|
+
let entries;
|
|
1544
|
+
try {
|
|
1545
|
+
entries = await fs.readdir(sourceDir);
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
if (error.code === 'ENOENT') {
|
|
1548
|
+
continue;
|
|
1372
1549
|
}
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1550
|
+
throw error;
|
|
1551
|
+
}
|
|
1552
|
+
for (const entry of entries) {
|
|
1553
|
+
const sourcePath = path.join(sourceDir, entry);
|
|
1554
|
+
const stat = await fs.stat(sourcePath);
|
|
1555
|
+
if (stat.isFile()) {
|
|
1556
|
+
files.push({ sourceRel: `${dir}/${entry}`, dir, name: entry, sourceRoot });
|
|
1379
1557
|
}
|
|
1380
1558
|
}
|
|
1381
1559
|
}
|
|
1560
|
+
return files;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
async function listBundleSourceFiles(edition) {
|
|
1564
|
+
// Order is preserved from when this was one nested loop over
|
|
1565
|
+
// ['common', edition] x ['references', 'templates'] — the extraction is
|
|
1566
|
+
// mechanical, and callers that key on array order see no change.
|
|
1567
|
+
const files = [
|
|
1568
|
+
...(await scanBundleSourceRoot('common')),
|
|
1569
|
+
...(await scanBundleSourceRoot(edition))
|
|
1570
|
+
];
|
|
1382
1571
|
|
|
1383
1572
|
assertNoBundleCollision(files);
|
|
1384
1573
|
assertEditionBundleComplete(files, edition);
|
|
@@ -1386,6 +1575,8 @@ async function listBundleSourceFiles(edition) {
|
|
|
1386
1575
|
return files;
|
|
1387
1576
|
}
|
|
1388
1577
|
|
|
1578
|
+
const BUNDLE_EDITIONS = Object.freeze(['greenfield', 'brownfield']);
|
|
1579
|
+
|
|
1389
1580
|
// Reads the per-project workflow bundle manifest, distinguishing "absent"
|
|
1390
1581
|
// (normal: fresh init / first projection) from "corrupt" (unreadable or invalid
|
|
1391
1582
|
// shape). A corrupt manifest must NOT be treated as absent: that would silently
|
|
@@ -1623,6 +1814,483 @@ async function readPackagedBundleFile(sourceRoot, sourceRel) {
|
|
|
1623
1814
|
}
|
|
1624
1815
|
}
|
|
1625
1816
|
|
|
1817
|
+
// PROPOSAL-090 route B3 — refresh the Dflow-canonical half of the Git
|
|
1818
|
+
// principles starter. Same decision table as the guide above (user decision
|
|
1819
|
+
// 2026-06-08: skip + warn + offer; never rewrite unasked):
|
|
1820
|
+
// - well-formed markers -> replace the marked region in place
|
|
1821
|
+
// - no markers, recognizable -> skip + warn, flag for interactive adoption
|
|
1822
|
+
// - no markers, unrecognizable -> skip + warn
|
|
1823
|
+
// - malformed marker pair -> skip + warn (never guess)
|
|
1824
|
+
//
|
|
1825
|
+
// ⚠ The region is sections 1-5 only. Sections 6+ (AI Collaboration Rules,
|
|
1826
|
+
// CI / CD) are project policy, and the file header carries a project-filled
|
|
1827
|
+
// `> Created: {YYYY-MM-DD}` — both stay outside. Known cost, accepted when the
|
|
1828
|
+
// route was chosen: canonical material that sits BELOW section 6 keeps drifting
|
|
1829
|
+
// (`## Related Documents` in all four; `## 7. Hotfixes under Trunk-based` in the
|
|
1830
|
+
// greenfield trunk starter, which is a drift that has actually happened).
|
|
1831
|
+
async function addGitPrinciplesItem(cwd, items, warnings, answers) {
|
|
1832
|
+
const policy = answers.gitPolicy === 'gitflow' || answers.gitPolicy === 'trunk' ? answers.gitPolicy : null;
|
|
1833
|
+
if (!policy) {
|
|
1834
|
+
// ⚠ Say so rather than doing nothing. The policy comes from
|
|
1835
|
+
// `_conventions.md` § Git Policy, and a missing section silently disables
|
|
1836
|
+
// every downstream consumer — the defect PROPOSAL-091 exists to fix. A
|
|
1837
|
+
// silent no-op here would add one more.
|
|
1838
|
+
warnings.push(
|
|
1839
|
+
'Could not determine the selected Git policy from `dflow/specs/shared/_conventions.md`, so `Git-principles-*.md` was not refreshed. Restore its `## Git Policy` section (`Selected Git policy: \`gitflow\`` or `\`trunk\``) and re-run.'
|
|
1840
|
+
);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
const relativePath = `dflow/specs/shared/Git-principles-${policy}.md`;
|
|
1845
|
+
const source = `packaged:${answers.edition}/scaffolding/Git-principles-${policy}.md`;
|
|
1846
|
+
const absolute = path.join(cwd, relativePath);
|
|
1847
|
+
|
|
1848
|
+
let packaged = null;
|
|
1849
|
+
let packagedError = null;
|
|
1850
|
+
try {
|
|
1851
|
+
packaged = await readPackagedTemplate(answers.edition, `scaffolding/Git-principles-${policy}.md`);
|
|
1852
|
+
} catch (error) {
|
|
1853
|
+
packagedError = error;
|
|
1854
|
+
}
|
|
1855
|
+
// ⚠⚠ AN UNREADABLE PACKAGED STARTER IS A BROKEN INSTALL, NOT A NO-OP. This
|
|
1856
|
+
// used to be `catch { packaged = null; }` followed by a silent `return`, and
|
|
1857
|
+
// `p090-b3-y1` executed what that bought: with the packaged starter deleted,
|
|
1858
|
+
// `doctor` printed "`dflow configure-agents` fails on this package before
|
|
1859
|
+
// writing a byte" while `configure-agents` exited 0, said nothing about Git
|
|
1860
|
+
// principles, left stale canonical content in place, and still advanced the
|
|
1861
|
+
// `> Dflow Version:` reconciliation line. The doctor sentence was a FALSE
|
|
1862
|
+
// CLAIM ABOUT ANOTHER COMMAND — the precise failure this whole line of work
|
|
1863
|
+
// exists to remove, committed while removing it.
|
|
1864
|
+
// ⚠ It throws rather than warns, for the same reason the malformed-marker
|
|
1865
|
+
// case below does: both mean the shipped package cannot answer what this
|
|
1866
|
+
// function is for, and the four starters all ship, so neither state is
|
|
1867
|
+
// reachable on a healthy install. Failing loudly keeps the two commands
|
|
1868
|
+
// saying the same thing about one package.
|
|
1869
|
+
if (packaged === null) {
|
|
1870
|
+
throw new InitError(
|
|
1871
|
+
`Internal error: packaged Git-principles-${policy}.md could not be read (${packagedError && packagedError.message ? packagedError.message : packagedError}).`
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const packagedRegion = classifyMarkedRegion(
|
|
1876
|
+
packaged,
|
|
1877
|
+
GIT_PRINCIPLES_CANONICAL_START,
|
|
1878
|
+
GIT_PRINCIPLES_CANONICAL_END
|
|
1879
|
+
);
|
|
1880
|
+
if (packagedRegion.state !== 'present') {
|
|
1881
|
+
throw new InitError(
|
|
1882
|
+
`Internal error: packaged Git-principles-${policy}.md has no well-formed git-principles-canonical markers.`
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
if (!(await pathExists(absolute))) {
|
|
1887
|
+
// Seeding is `dflow init`'s job (it picks the policy). If the file is gone,
|
|
1888
|
+
// doctor reports it; configure-agents does not silently re-create it,
|
|
1889
|
+
// because a project that deleted it may have meant to.
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const existingContent = await fs.readFile(absolute, 'utf8');
|
|
1894
|
+
const eol = detectDominantEol(existingContent);
|
|
1895
|
+
const lf = toLf(existingContent);
|
|
1896
|
+
const region = classifyMarkedRegion(lf, GIT_PRINCIPLES_CANONICAL_START, GIT_PRINCIPLES_CANONICAL_END);
|
|
1897
|
+
|
|
1898
|
+
const skipItem = (notes) => {
|
|
1899
|
+
items.push({
|
|
1900
|
+
relativePath,
|
|
1901
|
+
source,
|
|
1902
|
+
notes,
|
|
1903
|
+
content: packaged,
|
|
1904
|
+
action: 'skip',
|
|
1905
|
+
intentionalSkip: true,
|
|
1906
|
+
size: Buffer.byteLength(packaged, 'utf8')
|
|
1907
|
+
});
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
if (region.state === 'present') {
|
|
1911
|
+
const refreshed =
|
|
1912
|
+
lf.slice(0, region.startIdx) +
|
|
1913
|
+
packaged.slice(packagedRegion.startIdx, packagedRegion.endIdx) +
|
|
1914
|
+
lf.slice(region.endIdx);
|
|
1915
|
+
if (refreshed === lf) {
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
pushRootInjectItem(items, {
|
|
1919
|
+
relativePath,
|
|
1920
|
+
source,
|
|
1921
|
+
notes: 'refreshed Dflow-canonical Git principles (sections 1-5; your own sections kept)',
|
|
1922
|
+
content: applyEol(refreshed, eol),
|
|
1923
|
+
expectedContent: existingContent
|
|
1924
|
+
});
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
if (region.state === 'malformed') {
|
|
1929
|
+
warnings.push(
|
|
1930
|
+
`Existing ${relativePath} contains malformed git-principles-canonical markers; left it untouched. Repair or remove the stray markers and re-run so Dflow can refresh the canonical sections.`
|
|
1931
|
+
);
|
|
1932
|
+
skipItem('Git principles starter, malformed git-principles-canonical markers; left untouched');
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
const bounds = gitPrinciplesCanonicalBounds(lf);
|
|
1937
|
+
if (bounds) {
|
|
1938
|
+
if (answers.adoptGitPrinciplesMarkers) {
|
|
1939
|
+
// ⚠ The separators are rebuilt explicitly, not inherited from either side.
|
|
1940
|
+
// `bounds` names the two heading lines and the packaged region is bounded
|
|
1941
|
+
// by the marker text, so a naive three-way concat glues
|
|
1942
|
+
// `<!-- ... END -->` onto `## 6.` and silently restructures a section this
|
|
1943
|
+
// path promises to keep. (Whole-file EOL is normalized to the dominant
|
|
1944
|
+
// ending either way — pre-existing shared behaviour, so the promise is
|
|
1945
|
+
// content preservation, not byte equality, on a mixed-ending file.)
|
|
1946
|
+
// Caught by review round
|
|
1947
|
+
// `p090-b3-x1` as a critical finding, with a fixture that reproduced it.
|
|
1948
|
+
const nl = String.fromCharCode(10);
|
|
1949
|
+
const adopted =
|
|
1950
|
+
lf.slice(0, bounds.start) +
|
|
1951
|
+
nl +
|
|
1952
|
+
packaged.slice(packagedRegion.startIdx, packagedRegion.endIdx) +
|
|
1953
|
+
nl +
|
|
1954
|
+
nl +
|
|
1955
|
+
lf.slice(bounds.end);
|
|
1956
|
+
pushRootInjectItem(items, {
|
|
1957
|
+
relativePath,
|
|
1958
|
+
source,
|
|
1959
|
+
notes: 'adopted git-principles-canonical markers (kept your sections 6+ and the file header)',
|
|
1960
|
+
content: applyEol(adopted, eol),
|
|
1961
|
+
expectedContent: existingContent
|
|
1962
|
+
});
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
warnings.push(
|
|
1966
|
+
`${relativePath} predates Dflow's git-principles-canonical markers, so its canonical sections stay at the Dflow version that wrote them. Re-run \`dflow configure-agents\` on an interactive terminal and accept the marker-adoption offer, or reconcile manually against a fresh \`dflow init\`.`
|
|
1967
|
+
);
|
|
1968
|
+
skipItem('Git principles starter, no git-principles-canonical markers; left untouched');
|
|
1969
|
+
items[items.length - 1].offerGitPrinciplesAdoption = true;
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
warnings.push(
|
|
1974
|
+
`Existing ${relativePath} is not recognizable as a Dflow Git principles starter (its \`${GIT_PRINCIPLES_CANONICAL_FIRST_HEADING}\` / \`${GIT_PRINCIPLES_CANONICAL_AFTER_HEADING}\` headings were not both found); left it untouched.`
|
|
1975
|
+
);
|
|
1976
|
+
skipItem('Git principles starter, not recognizable; left untouched');
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// The byte span sections 1-5 occupy in a pre-marker file: from the start of the
|
|
1980
|
+
// `## 1.` heading line to the start of the `## 6.` heading line.
|
|
1981
|
+
// ⚠ Search the MASK, slice the ORIGINAL — same rule as `classifyMarkedRegion`.
|
|
1982
|
+
// A heading shown inside a fenced example is documentation, not a boundary.
|
|
1983
|
+
// Returns null unless BOTH anchors are found exactly once, in order; anything
|
|
1984
|
+
// else is unrecognizable and must not be guessed at.
|
|
1985
|
+
function gitPrinciplesCanonicalBounds(lfContent) {
|
|
1986
|
+
const searchable = doctorChecks.maskCodeBlocks(lfContent);
|
|
1987
|
+
const nl = String.fromCharCode(10);
|
|
1988
|
+
const first = nl + GIT_PRINCIPLES_CANONICAL_FIRST_HEADING + nl;
|
|
1989
|
+
const after = nl + GIT_PRINCIPLES_CANONICAL_AFTER_HEADING + nl;
|
|
1990
|
+
if (countOccurrences(searchable, first) !== 1 || countOccurrences(searchable, after) !== 1) {
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1993
|
+
// `start` lands ON the newline that precedes `## 1.` and `end` on the `#` of
|
|
1994
|
+
// `## 6.`, so the caller owns every separator between the three pieces it
|
|
1995
|
+
// joins. Making both point at the `#` is what produced the glued
|
|
1996
|
+
// `<!-- ... END -->## 6.` that review round `p090-b3-x1` reproduced.
|
|
1997
|
+
const start = searchable.indexOf(first);
|
|
1998
|
+
const end = searchable.indexOf(after) + 1;
|
|
1999
|
+
if (start >= end) {
|
|
2000
|
+
return null;
|
|
2001
|
+
}
|
|
2002
|
+
return { start, end };
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// PROPOSAL-058: the canonical AI agent guide is user-owned (it embeds the
|
|
2006
|
+
// project's "## Project Context"), but most of its body is Dflow-canonical
|
|
2007
|
+
// content that upgrades must be able to refresh — a guide frozen at its init
|
|
2008
|
+
// version leaves the re-projected workflow bundle § referencing sections the
|
|
2009
|
+
// guide does not have. The packaged template wraps the canonical body in
|
|
2010
|
+
// guide-canonical START/END markers. Decision table for an existing guide
|
|
2011
|
+
// (user decision 2026-06-08: skip + warn + offer; never rewrite unasked):
|
|
2012
|
+
// - well-formed markers -> replace the marked region in place (idempotent)
|
|
2013
|
+
// - no markers, recognizable -> skip + warn, and flag the item so an
|
|
2014
|
+
// interactive run can offer marker adoption. Adoption replaces everything
|
|
2015
|
+
// outside "## Project Context" with this version's canonical guide content;
|
|
2016
|
+
// it is consent-gated because historical canonical text cannot be verified
|
|
2017
|
+
// against the current package — only the user knows whether they customized
|
|
2018
|
+
// sections outside Project Context.
|
|
2019
|
+
// - no markers, unrecognizable -> skip + warn
|
|
2020
|
+
// - malformed marker pair -> skip + warn (never guess)
|
|
2021
|
+
async function addCanonicalGuideItem(cwd, items, warnings, packagedGuide, answers) {
|
|
2022
|
+
const source = `packaged:${answers.edition}/scaffolding/AI-AGENT-GUIDE.md`;
|
|
2023
|
+
const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
|
|
2024
|
+
|
|
2025
|
+
if (!(await pathExists(guidePath))) {
|
|
2026
|
+
items.push({
|
|
2027
|
+
relativePath: AI_AGENT_GUIDE_DEST,
|
|
2028
|
+
source,
|
|
2029
|
+
notes: 'canonical AI agent guide',
|
|
2030
|
+
content: packagedGuide
|
|
2031
|
+
});
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
const packagedRegion = classifyMarkedRegion(
|
|
2036
|
+
packagedGuide,
|
|
2037
|
+
GUIDE_CANONICAL_SECTION_START,
|
|
2038
|
+
GUIDE_CANONICAL_SECTION_END
|
|
2039
|
+
);
|
|
2040
|
+
if (packagedRegion.state !== 'present') {
|
|
2041
|
+
throw new InitError('Internal error: packaged AI-AGENT-GUIDE.md has no well-formed guide-canonical markers.');
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const existingContent = await fs.readFile(guidePath, 'utf8');
|
|
2045
|
+
const eol = detectDominantEol(existingContent);
|
|
2046
|
+
const lf = toLf(existingContent);
|
|
2047
|
+
const region = classifyMarkedRegion(lf, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
|
|
2048
|
+
|
|
2049
|
+
const skipItem = (notes) => {
|
|
2050
|
+
items.push({
|
|
2051
|
+
relativePath: AI_AGENT_GUIDE_DEST,
|
|
2052
|
+
source,
|
|
2053
|
+
notes,
|
|
2054
|
+
content: packagedGuide,
|
|
2055
|
+
action: 'skip',
|
|
2056
|
+
intentionalSkip: true,
|
|
2057
|
+
size: Buffer.byteLength(packagedGuide, 'utf8')
|
|
2058
|
+
});
|
|
2059
|
+
};
|
|
2060
|
+
|
|
2061
|
+
if (region.state === 'present') {
|
|
2062
|
+
const refreshed =
|
|
2063
|
+
lf.slice(0, region.startIdx) +
|
|
2064
|
+
packagedGuide.slice(packagedRegion.startIdx, packagedRegion.endIdx) +
|
|
2065
|
+
lf.slice(region.endIdx);
|
|
2066
|
+
pushRootInjectItem(items, {
|
|
2067
|
+
relativePath: AI_AGENT_GUIDE_DEST,
|
|
2068
|
+
source,
|
|
2069
|
+
notes: 'refreshed Dflow-canonical guide content (content outside the markers kept)',
|
|
2070
|
+
content: applyEol(refreshed, eol),
|
|
2071
|
+
expectedContent: existingContent
|
|
2072
|
+
});
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
if (region.state === 'malformed') {
|
|
2077
|
+
warnings.push(
|
|
2078
|
+
`Existing ${AI_AGENT_GUIDE_DEST} contains malformed guide-canonical markers; left it untouched. Repair or remove the stray markers and re-run so Dflow can refresh the canonical content.`
|
|
2079
|
+
);
|
|
2080
|
+
skipItem('canonical AI agent guide, malformed guide-canonical markers; left untouched');
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
if (isRecognizableDflowGuide(lf)) {
|
|
2085
|
+
if (answers.adoptGuideMarkers) {
|
|
2086
|
+
pushRootInjectItem(items, {
|
|
2087
|
+
relativePath: AI_AGENT_GUIDE_DEST,
|
|
2088
|
+
source,
|
|
2089
|
+
notes: 'adopted guide-canonical markers (kept your "## Project Context" section)',
|
|
2090
|
+
content: applyEol(transplantProjectContext(packagedGuide, lf), eol),
|
|
2091
|
+
expectedContent: existingContent
|
|
2092
|
+
});
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
warnings.push(
|
|
2096
|
+
`${AI_AGENT_GUIDE_DEST} predates Dflow's guide-canonical markers, so its canonical sections stay at the Dflow version that wrote them. Re-run \`dflow configure-agents\` on an interactive terminal and accept the marker-adoption offer, or reconcile manually against a fresh \`dflow init\`.`
|
|
2097
|
+
);
|
|
2098
|
+
const item = 'canonical AI agent guide, no guide-canonical markers; left untouched';
|
|
2099
|
+
skipItem(item);
|
|
2100
|
+
items[items.length - 1].offerGuideAdoption = true;
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
warnings.push(
|
|
2105
|
+
`Existing ${AI_AGENT_GUIDE_DEST} is not recognizable as a Dflow guide; left it untouched.`
|
|
2106
|
+
);
|
|
2107
|
+
skipItem('canonical AI agent guide, not recognizable as a Dflow guide; left untouched');
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// Fence-aware, and deliberately the same predicate transplantProjectContext
|
|
2111
|
+
// relies on: recognizability must imply locatable Project Context bounds, or
|
|
2112
|
+
// an accepted adoption offer would abort on the internal error below
|
|
2113
|
+
// (PROPOSAL-076 gate G3 — a guide whose only "## Project Context" heading sat
|
|
2114
|
+
// inside a fenced example was offered adoption and then crashed the run).
|
|
2115
|
+
// \u26A0 The title test compares the heading TEXT the shared classification produces,
|
|
2116
|
+
// rather than matching the raw line. That closes the last hand-rolled heading
|
|
2117
|
+
// rule outside `doctor-checks.js`, and it moves the behaviour twice, in opposite
|
|
2118
|
+
// directions \u2014 both deliberate, both pinned:
|
|
2119
|
+
// - WIDER: a 0-3 space indent is a heading to CommonMark, and this line now
|
|
2120
|
+
// accepts one. The old `^#` refused it while `projectContextSectionBounds`
|
|
2121
|
+
// one function below already allowed it, so a guide indented by one space
|
|
2122
|
+
// had a locatable Project Context and an unrecognizable title \u2014 the two
|
|
2123
|
+
// halves of this very expression disagreeing.
|
|
2124
|
+
// - NARROWER: the old `\s*$` also tolerated a trailing U+00A0 / form feed /
|
|
2125
|
+
// vertical tab. CommonMark strips only spaces and tabs, so such a title is
|
|
2126
|
+
// genuinely a different heading, and it is now reported as unrecognizable
|
|
2127
|
+
// instead of being silently accepted. That direction is safe by
|
|
2128
|
+
// construction: the caller's response to "not recognizable" is a warning
|
|
2129
|
+
// plus leaving the user's file untouched, which is a loud, actionable
|
|
2130
|
+
// failure rather than a silent rewrite of a file we misread.
|
|
2131
|
+
function isRecognizableDflowGuide(lfContent) {
|
|
2132
|
+
const content = lfContent.replace(/^\uFEFF/, ''); // a BOM must not defeat the title line (gate G4)
|
|
2133
|
+
const scan = doctorChecks.blankFencedBlocks(content);
|
|
2134
|
+
const titled = doctorChecks.classifyLines(scan)
|
|
2135
|
+
.some((c) => c.heading && c.heading.level === 1 && c.heading.text === 'Dflow AI Agent Guide');
|
|
2136
|
+
return titled && projectContextSectionBounds(content) !== null;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// Bounds of the "## Project Context" section in LF content: the heading line up
|
|
2140
|
+
// to (exclusive) the next "## " heading, the guide-canonical START marker, or EOF.
|
|
2141
|
+
// Fence-aware (PROPOSAL-076 gate G1): headings or markers inside ``` / ~~~
|
|
2142
|
+
// examples are content, not structure — the boundary scan runs on a
|
|
2143
|
+
// fence-blanked shadow while the returned lines stay the real content.
|
|
2144
|
+
// \u26A0 Heading detection here goes through `doctorChecks.classifyLines`, and that
|
|
2145
|
+
// is the point rather than a tidy-up. This function used to hand-roll its own
|
|
2146
|
+
// ATX rules, which made it a FOURTH place deciding what a heading is; `g5`
|
|
2147
|
+
// finding 2 caught it a patch behind its siblings \u2014 it had taken the 0-3 space
|
|
2148
|
+
// indent but not the "space or tab after the hashes" rule, so a bare `##` did
|
|
2149
|
+
// not end the section and a `| Tech stack |` row below it was read as though it
|
|
2150
|
+
// were still inside Project Context. Routing it through the classification is
|
|
2151
|
+
// what makes that class of lag impossible rather than merely fixed once.
|
|
2152
|
+
//
|
|
2153
|
+
// Two consequences of using the shared rules, both verified against the
|
|
2154
|
+
// packaged guides in `test/upgrade-drift.mjs`:
|
|
2155
|
+
// - the section now also ends at a LEVEL-1 heading, not only an H2. Both
|
|
2156
|
+
// packaged guides carry exactly one H1 and it is line 1, above Project
|
|
2157
|
+
// Context, so nothing packaged moves; an adopter's guide with an H1 further
|
|
2158
|
+
// down now ends the section there, which is what CommonMark says.
|
|
2159
|
+
// - `## Project Context ##` and a setext-underlined `Project Context` are now
|
|
2160
|
+
// found, where the old literal test saw neither.
|
|
2161
|
+
function projectContextSectionBounds(lfContent) {
|
|
2162
|
+
const stripped = lfContent.replace(/^\uFEFF/, ''); // keep BOM out of the line-0 heading match (gate G4)
|
|
2163
|
+
const lines = stripped.split('\n');
|
|
2164
|
+
const scan = doctorChecks.blankFencedBlocks(stripped);
|
|
2165
|
+
const info = doctorChecks.classifyLines(scan);
|
|
2166
|
+
// ⚠⚠ `heading.start`, NOT the array index. `classifyLines` reports a SETEXT
|
|
2167
|
+
// heading at its UNDERLINE line, and carries the real first line in
|
|
2168
|
+
// `heading.start`. Using the index here — while this function had just been
|
|
2169
|
+
// widened to recognize setext headings at all — meant the returned slice began
|
|
2170
|
+
// at the underline: `transplantProjectContext` then dropped the heading text
|
|
2171
|
+
// `Project Context`, swallowed the NEXT section's heading text into the kept
|
|
2172
|
+
// region, and wrote the result to the user's guide. Re-parsing that output
|
|
2173
|
+
// returns null, so the guide becomes permanently unrecognizable and
|
|
2174
|
+
// `configure-agents` refuses to touch it again — all behind an interactive
|
|
2175
|
+
// offer whose text promises "your Project Context section is kept".
|
|
2176
|
+
// CONTENT LOSS, on disk. `conventionsSectionBodies` had this right; this
|
|
2177
|
+
// function was widened without taking the same field with it.
|
|
2178
|
+
const at = info.findIndex((c) => c.heading && c.heading.level === 2 && c.heading.text === 'Project Context');
|
|
2179
|
+
if (at < 0) return null;
|
|
2180
|
+
const start = info[at].heading.start;
|
|
2181
|
+
let end = scan.length;
|
|
2182
|
+
for (let i = at + 1; i < scan.length; i += 1) {
|
|
2183
|
+
if (info[i].heading && info[i].heading.level <= 2) {
|
|
2184
|
+
end = info[i].heading.start;
|
|
2185
|
+
break;
|
|
2186
|
+
}
|
|
2187
|
+
if (scan[i].startsWith(GUIDE_CANONICAL_SECTION_START)) {
|
|
2188
|
+
end = i;
|
|
2189
|
+
break;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return { lines, start, end };
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// PROPOSAL-058 bootstrap: rebuild the guide from the packaged template, carrying
|
|
2196
|
+
// over the project's own "## Project Context" section (trailing blank lines normalized; the only
|
|
2197
|
+
// user-specific region by contract). Everything else — including any prose the
|
|
2198
|
+
// user kept outside Project Context — is replaced; the interactive offer says so
|
|
2199
|
+
// and defaults to No.
|
|
2200
|
+
function transplantProjectContext(packagedGuide, existingLf) {
|
|
2201
|
+
const existing = projectContextSectionBounds(existingLf);
|
|
2202
|
+
const packaged = projectContextSectionBounds(packagedGuide);
|
|
2203
|
+
if (!existing || !packaged) {
|
|
2204
|
+
throw new InitError('Internal error: cannot locate "## Project Context" while adopting guide markers.');
|
|
2205
|
+
}
|
|
2206
|
+
const existingSection = existing.lines.slice(existing.start, existing.end);
|
|
2207
|
+
// ⚠ `BLANK_LINE_RE` and not `.trim()`: a line holding only a U+00A0 is NOT
|
|
2208
|
+
// blank to CommonMark, and `.trim()` would pop it off the end of a section
|
|
2209
|
+
// this function is about to write back to a user-owned file.
|
|
2210
|
+
// ⚠ Imported rather than written inline. The first fix here spelled
|
|
2211
|
+
// `/^[ \t]*$/` by hand, which made it a THIRD copy of the rule while the
|
|
2212
|
+
// commit message claimed there were only two — the same "two expressions, one
|
|
2213
|
+
// rule" shape the whole rewrite exists to remove.
|
|
2214
|
+
while (existingSection.length > 0
|
|
2215
|
+
&& doctorChecks.BLANK_LINE_RE.test(existingSection[existingSection.length - 1])) {
|
|
2216
|
+
existingSection.pop();
|
|
2217
|
+
}
|
|
2218
|
+
return [
|
|
2219
|
+
...packaged.lines.slice(0, packaged.start),
|
|
2220
|
+
...existingSection,
|
|
2221
|
+
'',
|
|
2222
|
+
...packaged.lines.slice(packaged.end)
|
|
2223
|
+
].join('\n');
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// PROPOSAL-058: the interactive adoption questions. Only a TTY run ever asks
|
|
2227
|
+
// (the PROPOSAL-074 non-TTY contract: never consume a stdin slot); blank answers
|
|
2228
|
+
// mean No because both offers rewrite a user-owned file.
|
|
2229
|
+
async function askGuideMarkerAdoption(rl, stdout) {
|
|
2230
|
+
stdout.write(
|
|
2231
|
+
`\nYour ${AI_AGENT_GUIDE_DEST} predates Dflow's managed canonical markers, so\n` +
|
|
2232
|
+
'upgrades cannot refresh its canonical sections in place. Adopting the markers\n' +
|
|
2233
|
+
'replaces everything OUTSIDE "## Project Context" with this Dflow version\'s\n' +
|
|
2234
|
+
'canonical guide content (your Project Context section is kept).\n' +
|
|
2235
|
+
'Answer N if you customized any other guide section.\n'
|
|
2236
|
+
);
|
|
2237
|
+
return askConfirmation(rl, 'Adopt the managed guide markers now? (y/N) ');
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
async function askGitPrinciplesMarkerAdoption(rl, stdout) {
|
|
2241
|
+
const nl = String.fromCharCode(10);
|
|
2242
|
+
stdout.write(
|
|
2243
|
+
nl +
|
|
2244
|
+
"Your Git-principles-*.md predates Dflow's managed canonical markers, so" + nl +
|
|
2245
|
+
'upgrades cannot refresh its canonical sections in place. Adopting the markers' + nl +
|
|
2246
|
+
"replaces sections 1-5 with this Dflow version's content; your file header and" + nl +
|
|
2247
|
+
'everything from "## 6. AI Collaboration Rules (Project Policy)" down —' + nl +
|
|
2248
|
+
'including your CI / CD section — is kept as you wrote it. (Line endings are' + nl +
|
|
2249
|
+
'unified to whichever this file already uses most, as on every write.)' + nl +
|
|
2250
|
+
'Answer N if you customized any of sections 1-5.' + nl
|
|
2251
|
+
);
|
|
2252
|
+
return askConfirmation(rl, 'Adopt the managed Git principles markers now? (y/N) ');
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
async function askShimBlockAdoption(rl, stdout, agent) {
|
|
2256
|
+
const relativePath = getAiAgentTarget(agent).relativePath;
|
|
2257
|
+
stdout.write(
|
|
2258
|
+
`\n${relativePath} references the Dflow guide but is not Dflow-managed (no markers),\n` +
|
|
2259
|
+
'so upgrades cannot refresh any Dflow wording inside it. Dflow can append its\n' +
|
|
2260
|
+
'managed, marker-delimited block at the end of the file; afterwards remove any\n' +
|
|
2261
|
+
'older Dflow wording you keep above the block.\n'
|
|
2262
|
+
);
|
|
2263
|
+
return askConfirmation(rl, `Append the managed Dflow block to ${relativePath}? (y/N) `);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
// PROPOSAL-058 (user decision 2026-06-08, OQ2): `> Dflow Version:` in
|
|
2267
|
+
// _conventions.md means "the Dflow version this project last reconciled with",
|
|
2268
|
+
// so a successful configure-agents run must advance it — before this it froze at
|
|
2269
|
+
// the init version, which was a bug, not a design. Narrow single-line rewrite of
|
|
2270
|
+
// a user-owned file: previewed like every plan item, guarded by the rootInject
|
|
2271
|
+
// raw-equality check, and never *added* when the line is absent (doctor reports
|
|
2272
|
+
// that case instead).
|
|
2273
|
+
async function addConventionsVersionReconcileItem(cwd, items) {
|
|
2274
|
+
const relativePath = 'dflow/specs/shared/_conventions.md';
|
|
2275
|
+
const absolute = path.join(cwd, relativePath);
|
|
2276
|
+
if (!(await pathExists(absolute))) return;
|
|
2277
|
+
const existingContent = await fs.readFile(absolute, 'utf8');
|
|
2278
|
+
const lf = toLf(existingContent);
|
|
2279
|
+
const match = lf.match(/^> Dflow Version:[ \t]*(.*)$/m);
|
|
2280
|
+
if (!match || match[1].trim() === pkg.version) return;
|
|
2281
|
+
const eol = detectDominantEol(existingContent);
|
|
2282
|
+
const updated = lf.replace(/^> Dflow Version:[ \t]*.*$/m, `> Dflow Version: ${pkg.version}`);
|
|
2283
|
+
pushRootInjectItem(items, {
|
|
2284
|
+
relativePath,
|
|
2285
|
+
source: 'generated:dflow-version-reconcile',
|
|
2286
|
+
notes: `update Dflow Version line to ${pkg.version} (last reconciled)`,
|
|
2287
|
+
content: applyEol(updated, eol),
|
|
2288
|
+
expectedContent: existingContent
|
|
2289
|
+
});
|
|
2290
|
+
// See the call-site comment: never advance the line over a guarded skip.
|
|
2291
|
+
items[items.length - 1].requiresFullApply = true;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
1626
2294
|
// PROPOSAL-054: configure a tool's root agent file. A non-guide existing file used
|
|
1627
2295
|
// to be parked as a side merge snippet ("hand-merge this yourself"); it is now an
|
|
1628
2296
|
// auto-injected, marker-delimited Dflow block shown in the confirmation preview.
|
|
@@ -1673,7 +2341,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
|
|
|
1673
2341
|
|
|
1674
2342
|
const existingContent = await fs.readFile(targetPath, 'utf8');
|
|
1675
2343
|
const eol = detectDominantEol(existingContent);
|
|
1676
|
-
const lf = existingContent
|
|
2344
|
+
const lf = toLf(existingContent);
|
|
1677
2345
|
const agentRegion = classifyMarkedRegion(lf, AGENT_SHIM_SECTION_START, AGENT_SHIM_SECTION_END);
|
|
1678
2346
|
// Classify the Codex trigger region on EVERY AGENTS.md run (not only when we are
|
|
1679
2347
|
// about to manage the trigger): even a non---command-adapters run replaces the
|
|
@@ -1779,7 +2447,40 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
|
|
|
1779
2447
|
// marker-managed (a guide-configured file the user wrote / heavily edited). Keep
|
|
1780
2448
|
// the base shim skipped so we never duplicate their guide pointer. Under Codex
|
|
1781
2449
|
// --command-adapters still install / update the self-delimited trigger block (OQ#6c).
|
|
2450
|
+
// PROPOSAL-058: any Dflow wording inside such a file is frozen (old canonical
|
|
2451
|
+
// prose and user prose cannot be told apart mechanically), so an interactive run
|
|
2452
|
+
// offers to append the managed block instead — with consent the file becomes
|
|
2453
|
+
// marker-managed (future runs refresh it via case 2b) and the user removes their
|
|
2454
|
+
// older Dflow wording; without consent (or non-TTY) the 2d behavior is unchanged.
|
|
1782
2455
|
if (contentReferencesAiAgentGuide(existingContent)) {
|
|
2456
|
+
const adoptShimAgents = options.adoptShimAgents || [];
|
|
2457
|
+
if (adoptShimAgents.includes(agent)) {
|
|
2458
|
+
let updated = appendBlockLf(lf, agentShimBlock);
|
|
2459
|
+
if (wantsTrigger) {
|
|
2460
|
+
updated = upsertCodexTriggerBlock(updated, triggerBlock);
|
|
2461
|
+
}
|
|
2462
|
+
if (warnings) {
|
|
2463
|
+
warnings.push(
|
|
2464
|
+
`Appended the managed Dflow block to ${target.relativePath}; future upgrades refresh it in place. Review the file and remove any older Dflow wording outside the marked block.`
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
2467
|
+
pushRootInjectItem(items, {
|
|
2468
|
+
relativePath: target.relativePath,
|
|
2469
|
+
source,
|
|
2470
|
+
notes: `selected, appended managed Dflow block to existing ${target.relativePath}`,
|
|
2471
|
+
content: applyEol(updated, eol),
|
|
2472
|
+
expectedContent: existingContent
|
|
2473
|
+
});
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
// No consent (declined, or a non-interactive run): keep the 2d skip, but say
|
|
2477
|
+
// so — the docs promise "skip and warn", and the frozen Dflow wording is
|
|
2478
|
+
// exactly the drift this proposal makes visible.
|
|
2479
|
+
if (warnings) {
|
|
2480
|
+
warnings.push(
|
|
2481
|
+
`${target.relativePath} references the Dflow guide but is not marker-managed; Dflow wording inside it stays frozen on upgrade. Re-run \`dflow configure-agents\` on an interactive terminal to accept the managed-block offer, or keep maintaining the file yourself (\`dflow doctor\` reports this state).`
|
|
2482
|
+
);
|
|
2483
|
+
}
|
|
1783
2484
|
if (wantsTrigger) {
|
|
1784
2485
|
pushRootInjectItem(items, {
|
|
1785
2486
|
relativePath: target.relativePath,
|
|
@@ -1788,6 +2489,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
|
|
|
1788
2489
|
content: applyEol(upsertCodexTriggerBlock(lf, triggerBlock), eol),
|
|
1789
2490
|
expectedContent: existingContent
|
|
1790
2491
|
});
|
|
2492
|
+
items[items.length - 1].offerShimAdoption = agent;
|
|
1791
2493
|
} else {
|
|
1792
2494
|
items.push({
|
|
1793
2495
|
relativePath: target.relativePath,
|
|
@@ -1796,6 +2498,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
|
|
|
1796
2498
|
content: fullShim,
|
|
1797
2499
|
action: 'skip',
|
|
1798
2500
|
intentionalSkip: true,
|
|
2501
|
+
offerShimAdoption: agent,
|
|
1799
2502
|
size: Buffer.byteLength(fullShim, 'utf8')
|
|
1800
2503
|
});
|
|
1801
2504
|
}
|
|
@@ -1879,7 +2582,7 @@ function appendBlockLf(lfContent, block) {
|
|
|
1879
2582
|
// dominant EOL (the approved EOL policy), so a pure LF / pure CRLF user prefix
|
|
1880
2583
|
// round-trips byte-for-byte. `blocks` are LF strings.
|
|
1881
2584
|
function appendDflowBlocks(existingContent, blocks, eol) {
|
|
1882
|
-
const lf = existingContent
|
|
2585
|
+
const lf = toLf(existingContent);
|
|
1883
2586
|
return applyEol(appendBlockLf(lf, blocks.join('\n\n')), eol);
|
|
1884
2587
|
}
|
|
1885
2588
|
|
|
@@ -1907,14 +2610,23 @@ function extractCodexTriggerBlock(content) {
|
|
|
1907
2610
|
// The markers are single-line HTML comments, so classification is identical on raw or
|
|
1908
2611
|
// LF-normalized content; callers slice on whichever string they passed in.
|
|
1909
2612
|
function classifyMarkedRegion(content, startMarker, endMarker) {
|
|
1910
|
-
|
|
1911
|
-
|
|
2613
|
+
// ⚠ SEARCH THE MASK, SLICE THE ORIGINAL. A marker shown inside a fenced or
|
|
2614
|
+
// indented code example is documentation, not a region boundary — treating it
|
|
2615
|
+
// as one overwrote a user's own example with generated content, silently
|
|
2616
|
+
// (`p082-b3-k3` finding 1). `maskCodeBlocks` preserves every offset, so the
|
|
2617
|
+
// indices returned here still address `content`.
|
|
2618
|
+
// ⚠ This is the same defect class as the one the conventions checks had: a
|
|
2619
|
+
// consumer deciding a structural question by not asking the classifier. It is
|
|
2620
|
+
// fixed here by asking.
|
|
2621
|
+
const searchable = doctorChecks.maskCodeBlocks(content);
|
|
2622
|
+
const startCount = countOccurrences(searchable, startMarker);
|
|
2623
|
+
const endCount = countOccurrences(searchable, endMarker);
|
|
1912
2624
|
if (startCount === 0 && endCount === 0) {
|
|
1913
2625
|
return { state: 'absent' };
|
|
1914
2626
|
}
|
|
1915
2627
|
if (startCount === 1 && endCount === 1) {
|
|
1916
|
-
const startIdx =
|
|
1917
|
-
const endInner =
|
|
2628
|
+
const startIdx = searchable.indexOf(startMarker);
|
|
2629
|
+
const endInner = searchable.indexOf(endMarker);
|
|
1918
2630
|
if (startIdx < endInner) {
|
|
1919
2631
|
return { state: 'present', startIdx, endIdx: endInner + endMarker.length };
|
|
1920
2632
|
}
|
|
@@ -1963,6 +2675,30 @@ function codexTriggerMarkersStraddle(content, start, end) {
|
|
|
1963
2675
|
// Dominant line ending of a user file, so injected blocks match it (Windows projects
|
|
1964
2676
|
// may be CRLF). The repo's own LF policy (.gitattributes) governs repo files only, not
|
|
1965
2677
|
// an adopter's project files.
|
|
2678
|
+
// Normalize any line ending to LF — CRLF **and a lone CR**, CommonMark's third
|
|
2679
|
+
// form. The lone-CR half is not pedantry. `lfContent` / `existingLf` are threaded
|
|
2680
|
+
// through the guide pipeline as a PROMISE that the content is LF, and
|
|
2681
|
+
// `projectContextSectionBounds` splits it on newline while its fence-aware scan
|
|
2682
|
+
// goes through `blankFencedBlocks`. While both sides ignored a lone CR they were
|
|
2683
|
+
// wrong together and therefore harmless — such a guide simply was not
|
|
2684
|
+
// recognized. Fixing only the scan side (`p082-b3-g1` finding 3) made them
|
|
2685
|
+
// DISAGREE: a CR-only guide became "recognizable", its Project Context sliced to
|
|
2686
|
+
// nothing, and `configure-agents` exited 0 having transplanted an EMPTY section
|
|
2687
|
+
// — silently discarding the one region that whole path exists to preserve.
|
|
2688
|
+
// Caught by `p082-b3-g2` finding 1, a regression this batch introduced.
|
|
2689
|
+
//
|
|
2690
|
+
// The lesson is the one this batch keeps paying for: a normalization is a
|
|
2691
|
+
// contract between everyone who reads the value, so it needs ONE definition.
|
|
2692
|
+
// Six call sites had each spelled the CRLF replacement out by hand, so "is this
|
|
2693
|
+
// LF now?" had six answers that agreed only by luck.
|
|
2694
|
+
//
|
|
2695
|
+
// ⚠ Consequence worth knowing: a CR-only file is rewritten with LF endings.
|
|
2696
|
+
// `detectDominantEol` only distinguishes CRLF from LF and already returned LF
|
|
2697
|
+
// for such a file, so this makes existing behaviour honest rather than new.
|
|
2698
|
+
function toLf(content) {
|
|
2699
|
+
return String(content).replace(/\r\n|\r/g, '\n');
|
|
2700
|
+
}
|
|
2701
|
+
|
|
1966
2702
|
function detectDominantEol(content) {
|
|
1967
2703
|
const crlf = (content.match(/\r\n/g) || []).length;
|
|
1968
2704
|
const lfOnly = (content.match(/\n/g) || []).length - crlf;
|
|
@@ -1970,7 +2706,7 @@ function detectDominantEol(content) {
|
|
|
1970
2706
|
}
|
|
1971
2707
|
|
|
1972
2708
|
function applyEol(content, eol) {
|
|
1973
|
-
const normalized = content
|
|
2709
|
+
const normalized = toLf(content);
|
|
1974
2710
|
return eol === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized;
|
|
1975
2711
|
}
|
|
1976
2712
|
|
|
@@ -1993,10 +2729,14 @@ function getAiAgentTarget(agent) {
|
|
|
1993
2729
|
return targets[agent];
|
|
1994
2730
|
}
|
|
1995
2731
|
|
|
1996
|
-
function
|
|
1997
|
-
|
|
2732
|
+
function shimTitle(targetPath) {
|
|
2733
|
+
return targetPath === '.github/copilot-instructions.md'
|
|
1998
2734
|
? 'GitHub Copilot Repository Instructions'
|
|
1999
2735
|
: `${targetPath} - Dflow Project Instructions`;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function buildAiAgentShim(targetPath, commandRegistry = []) {
|
|
2739
|
+
const title = shimTitle(targetPath);
|
|
2000
2740
|
|
|
2001
2741
|
const commandTriggerHint = targetPath === 'AGENTS.md' && commandRegistry.length > 0
|
|
2002
2742
|
? buildCodexCommandTriggerSection(commandRegistry)
|
|
@@ -2012,8 +2752,17 @@ domain behavior, a new requirement, or a bug-fix workflow — read and follow:
|
|
|
2012
2752
|
- \`dflow/specs/shared/AI-AGENT-GUIDE.md\` — command registry, routing rules, and project context.
|
|
2013
2753
|
- \`dflow/specs/shared/dflow-workflows/\` — vendored workflow bundle with executable step definitions.
|
|
2014
2754
|
|
|
2015
|
-
For routine work (refactors, renames, chores, formatting, dependency
|
|
2016
|
-
general code questions), proceed normally; you need not read the guide
|
|
2755
|
+
For routine work (refactors, renames, chores, formatting, routine dependency
|
|
2756
|
+
bumps, or general code questions), proceed normally; you need not read the guide
|
|
2757
|
+
first. **Routine is narrower than it sounds** — it excludes anything a product
|
|
2758
|
+
audience perceives (UI, email, exports, public docs such as a product README or
|
|
2759
|
+
API reference, and operator surfaces like dashboard labels and alerts), where
|
|
2760
|
+
**size is not the test**: a single-element wording or appearance change still
|
|
2761
|
+
counts. It also excludes anything touching architecture, data structure, a
|
|
2762
|
+
machine-consumed contract, a BR-ID, operational semantics (security / CVE,
|
|
2763
|
+
safety, resilience, compliance, payment), or deliberate performance / resource /
|
|
2764
|
+
SLA work. When unsure, read the guide's § Ceremony Scaling —
|
|
2765
|
+
it decides, not this page.
|
|
2017
2766
|
|
|
2018
2767
|
Keep tool-specific instruction files small. The guide and workflow bundle are
|
|
2019
2768
|
the authoritative sources for Dflow workflow rules, slash-command behavior,
|
|
@@ -2021,17 +2770,82 @@ spec locations, and SDD/DDD constraints.${commandTriggerHint}
|
|
|
2021
2770
|
`;
|
|
2022
2771
|
}
|
|
2023
2772
|
|
|
2024
|
-
//
|
|
2025
|
-
//
|
|
2026
|
-
//
|
|
2027
|
-
//
|
|
2028
|
-
//
|
|
2029
|
-
//
|
|
2030
|
-
//
|
|
2773
|
+
// ⚠⚠ READ BEFORE EDITING ANY VERSION NUMBER BELOW. The version→body map has now
|
|
2774
|
+
// been corrected in three consecutive review rounds, each time in a new way:
|
|
2775
|
+
// first the whole oldest body was missing; then the range was written as
|
|
2776
|
+
// starting at v0.1.0 (it starts at v0.1.1); then the handover to the next body
|
|
2777
|
+
// was pinned to v0.9.0 (it is 0.8.0). Per this repo's rule that a thing rewritten
|
|
2778
|
+
// three times is a design question rather than a fourth patch, the cause is
|
|
2779
|
+
// named here instead of the numbers being nudged again:
|
|
2780
|
+
//
|
|
2781
|
+
// **`git tag` is NOT the list of releases.** 0.8.0 shipped to npm and has no
|
|
2782
|
+
// tag — `git tag` jumps v0.7.0 → v0.9.0 — so any range re-derived from tags
|
|
2783
|
+
// alone will silently skip it.
|
|
2784
|
+
//
|
|
2785
|
+
// ⚠ `CHANGELOG.md` is not the list either, and an earlier version of this note
|
|
2786
|
+
// sent people there: it has no `## 0.13.0` heading — that entry was folded
|
|
2787
|
+
// away by the 0.14.0 release prep — so the same kind of hole reappears one
|
|
2788
|
+
// version along. Nor does `-S'"version": "0.X.0"'` enumerate: it can only
|
|
2789
|
+
// confirm a version you already suspect, and `X` is the unknown.
|
|
2790
|
+
//
|
|
2791
|
+
// **The one command that actually enumerates** (15 bumps, including untagged
|
|
2792
|
+
// 0.8.0 and CHANGELOG-less 0.13.0):
|
|
2793
|
+
//
|
|
2794
|
+
// git log -G'"version": "0\.' --oneline -- package.json
|
|
2795
|
+
//
|
|
2796
|
+
// Then read `buildAiAgentShim` at that release commit — not at the nearest tag.
|
|
2797
|
+
//
|
|
2798
|
+
// Nothing in the runtime depends on these numbers: `.some()` short-circuits over
|
|
2799
|
+
// the builders and no two shipped bodies normalize equal. They exist so a
|
|
2800
|
+
// maintainer can tell whether the list reaches all the way back — which is
|
|
2801
|
+
// exactly why being wrong about them has cost three rounds.
|
|
2802
|
+
//
|
|
2803
|
+
// Frozen pre-bundle shim body: the oldest body `buildAiAgentShim` ever
|
|
2804
|
+
// generated, carried by v0.1.1 through v0.7.0. One guide bullet, no
|
|
2805
|
+
// workflow-bundle bullet, and a "The Dflow guide above is the single source of
|
|
2806
|
+
// truth" tail. Introduced by `eabee5c` (earliest release tag v0.1.1) and
|
|
2807
|
+
// replaced by `5498120` (PROPOSAL-039, vendored workflow bundle), which first
|
|
2808
|
+
// shipped in **0.8.0** (release commit `66681d6`, untagged).
|
|
2809
|
+
//
|
|
2810
|
+
// ⚠ NOT "the first body Dflow ever shipped", and the range does not reach
|
|
2811
|
+
// v0.1.0. At v0.1.0 `lib/init.js` did not reference the guide at all — it wrote
|
|
2812
|
+
// CLAUDE.md from the packaged `CLAUDE-md-snippet.md` via a different code path,
|
|
2813
|
+
// producing a two-H2 `System Context` / `Development Workflow` document with
|
|
2814
|
+
// per-project values substituted in. That body is deliberately NOT in this list
|
|
2815
|
+
// and cannot be: a frozen builder matches a fixed string, and a
|
|
2816
|
+
// placeholder-substituted document has no fixed form to match. A v0.1.0 project
|
|
2817
|
+
// therefore degrades to the not-marker-managed branch, which is correct
|
|
2818
|
+
// behaviour for a file whose content is partly the developer's.
|
|
2819
|
+
//
|
|
2820
|
+
// It was missing from the frozen set until 2026-08-03, and the omission was
|
|
2821
|
+
// live: a project still carrying this body failed the match, fell to the
|
|
2822
|
+
// "not marker-managed" branch and kept the v0.1-v0.7 wording permanently.
|
|
2823
|
+
// Confirmed by planting it in a real project and running `configure-agents`.
|
|
2824
|
+
// The list comment said "oldest first" while starting at the second-oldest.
|
|
2825
|
+
function buildPreBundleAgentShimBody(targetPath) {
|
|
2826
|
+
const title = shimTitle(targetPath);
|
|
2827
|
+
|
|
2828
|
+
return `# ${title}
|
|
2829
|
+
|
|
2830
|
+
This project uses Dflow for spec-first AI-assisted development.
|
|
2831
|
+
|
|
2832
|
+
Before planning or editing code, read and follow:
|
|
2833
|
+
|
|
2834
|
+
- \`dflow/specs/shared/AI-AGENT-GUIDE.md\`
|
|
2835
|
+
|
|
2836
|
+
Keep tool-specific instruction files small. The Dflow guide above is the
|
|
2837
|
+
single source of truth for project workflow rules, slash-command behavior,
|
|
2838
|
+
spec locations, and SDD/DDD constraints.
|
|
2839
|
+
`;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
// Frozen pre-scoping shim body: shipped in **0.8.0 and v0.9.0** (and the
|
|
2843
|
+
// Phase-2 @import-removal interim) — "Before planning or editing code ...".
|
|
2844
|
+
// Its old wording is INTENTIONAL — this function exists to recognize it. See
|
|
2845
|
+
// FROZEN_SHIM_BODY_BUILDERS below for why, and the ⚠⚠ note above
|
|
2846
|
+
// buildPreBundleAgentShimBody before touching the version numbers.
|
|
2031
2847
|
function buildLegacyAgentShimBody(targetPath) {
|
|
2032
|
-
const title = targetPath
|
|
2033
|
-
? 'GitHub Copilot Repository Instructions'
|
|
2034
|
-
: `${targetPath} - Dflow Project Instructions`;
|
|
2848
|
+
const title = shimTitle(targetPath);
|
|
2035
2849
|
|
|
2036
2850
|
return `# ${title}
|
|
2037
2851
|
|
|
@@ -2048,6 +2862,79 @@ spec locations, and SDD/DDD constraints.
|
|
|
2048
2862
|
`;
|
|
2049
2863
|
}
|
|
2050
2864
|
|
|
2865
|
+
// Frozen unqualified-routine shim body (shipped 0.10.0 through 0.14.0): the
|
|
2866
|
+
// scoped wording before PROPOSAL-082 narrowed "routine". Its routine paragraph
|
|
2867
|
+
// waved through the classes the cascade calls tracked — a security dep bump, an
|
|
2868
|
+
// operational-axis refactor, a Domain rename — and told the agent not to read
|
|
2869
|
+
// the guide, so the shim contradicted the guide it points at.
|
|
2870
|
+
function buildUnqualifiedRoutineShimBody(targetPath) {
|
|
2871
|
+
const title = shimTitle(targetPath);
|
|
2872
|
+
|
|
2873
|
+
return `# ${title}
|
|
2874
|
+
|
|
2875
|
+
This project uses Dflow for spec-first AI-assisted development.
|
|
2876
|
+
|
|
2877
|
+
For spec-impacting work — a new feature, a change to product, user-facing, or
|
|
2878
|
+
domain behavior, a new requirement, or a bug-fix workflow — read and follow:
|
|
2879
|
+
|
|
2880
|
+
- \`dflow/specs/shared/AI-AGENT-GUIDE.md\` — command registry, routing rules, and project context.
|
|
2881
|
+
- \`dflow/specs/shared/dflow-workflows/\` — vendored workflow bundle with executable step definitions.
|
|
2882
|
+
|
|
2883
|
+
For routine work (refactors, renames, chores, formatting, dependency bumps, or
|
|
2884
|
+
general code questions), proceed normally; you need not read the guide first.
|
|
2885
|
+
|
|
2886
|
+
Keep tool-specific instruction files small. The guide and workflow bundle are
|
|
2887
|
+
the authoritative sources for Dflow workflow rules, slash-command behavior,
|
|
2888
|
+
spec locations, and SDD/DDD constraints.
|
|
2889
|
+
`;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
// Every shim body Dflow has shipped, oldest first. Used ONLY by
|
|
2893
|
+
// isPristineDflowAgentsShim, so an adopter carrying any previously-generated
|
|
2894
|
+
// whole-file shim is still recognized as Dflow-generated and regenerated to the
|
|
2895
|
+
// current wording.
|
|
2896
|
+
//
|
|
2897
|
+
// ⚠ CHANGING buildAiAgentShim's BODY MEANS APPENDING THE OUTGOING BODY HERE, in
|
|
2898
|
+
// the same commit. Miss it and every adopter on the previous release is stranded
|
|
2899
|
+
// on the guide-reference skip path: their shim stops matching, degrades to the
|
|
2900
|
+
// "not marker-managed" branch, and keeps the superseded body forever — plus, for
|
|
2901
|
+
// CLAUDE.md, the legacy @import. The failure is silent on the maintainer's box,
|
|
2902
|
+
// because a freshly-generated shim always matches the body it was just built
|
|
2903
|
+
// from; only an upgrade from a real older project shows it.
|
|
2904
|
+
//
|
|
2905
|
+
// The rule has exactly one exception, and it follows from that failure mode: an
|
|
2906
|
+
// outgoing body that **never appeared in a release** has no adopters to strand,
|
|
2907
|
+
// so it does not go in the list. ⚠ Establish that from the version-bump history
|
|
2908
|
+
// (the enumerating command in the ⚠⚠ note above), NOT from `git tag --contains`
|
|
2909
|
+
// — that note says why `git tag` is not the list of releases, and a body
|
|
2910
|
+
// introduced and replaced *between* two releases is exactly where the tag test
|
|
2911
|
+
// and the truth diverge. Taking the exception is not free: a project scaffolded
|
|
2912
|
+
// from this repo while the orphan body was current carries it, and
|
|
2913
|
+
// `configure-agents` then refuses to regenerate that file in place — loudly,
|
|
2914
|
+
// via the "not marker-managed" warning, leaving the file untouched.
|
|
2915
|
+
//
|
|
2916
|
+
// This is a list rather than a single fallback because the one-body form had to
|
|
2917
|
+
// be edited correctly at exactly the moment a body changed, which is the moment
|
|
2918
|
+
// attention is on the new wording. `test/agent-inject.mjs` carries one upgrade
|
|
2919
|
+
// case per entry (§3b / §3e / §3f, plus §3g for the AGENTS.md+trigger
|
|
2920
|
+
// composition) — NOT upgrade-drift.mjs, which has no
|
|
2921
|
+
// shim-body case at all.
|
|
2922
|
+
//
|
|
2923
|
+
// ⚠ Adding a builder here means adding its case there in the same commit. That
|
|
2924
|
+
// pairing is not machine-enforced; the list can grow without a test.
|
|
2925
|
+
//
|
|
2926
|
+
// The v0.1-v0.7 entry was missing from the first version of this list, so the
|
|
2927
|
+
// list shipped one release while contradicting its own "oldest first" label and
|
|
2928
|
+
// stranding the very adopters it names. Order is documentation only —
|
|
2929
|
+
// `.some()` short-circuits and no two shipped bodies normalize equal — but the
|
|
2930
|
+
// label has to be true, because it is what tells the next maintainer whether
|
|
2931
|
+
// the list reaches all the way back.
|
|
2932
|
+
const FROZEN_SHIM_BODY_BUILDERS = [
|
|
2933
|
+
buildPreBundleAgentShimBody,
|
|
2934
|
+
buildLegacyAgentShimBody,
|
|
2935
|
+
buildUnqualifiedRoutineShimBody
|
|
2936
|
+
];
|
|
2937
|
+
|
|
2051
2938
|
function addCommandAdapterItems(items, aiAgents, commandRegistry) {
|
|
2052
2939
|
if (aiAgents.includes('claude')) {
|
|
2053
2940
|
for (const command of commandRegistry) {
|
|
@@ -2231,12 +3118,11 @@ ${argHint}
|
|
|
2231
3118
|
}
|
|
2232
3119
|
|
|
2233
3120
|
function normalizeCommandAdapterFingerprint(content) {
|
|
2234
|
-
return
|
|
3121
|
+
return toLf(content);
|
|
2235
3122
|
}
|
|
2236
3123
|
|
|
2237
3124
|
function normalizeShimForMatch(content) {
|
|
2238
|
-
return
|
|
2239
|
-
.replace(/\r\n/g, '\n')
|
|
3125
|
+
return toLf(content)
|
|
2240
3126
|
.split('\n')
|
|
2241
3127
|
.map((line) => line.replace(/[ \t]+$/, ''))
|
|
2242
3128
|
.join('\n')
|
|
@@ -2283,12 +3169,13 @@ function isPristineDflowAgentsShim(existingContent, baseShim, relativePath) {
|
|
|
2283
3169
|
if (existing === target) {
|
|
2284
3170
|
return true;
|
|
2285
3171
|
}
|
|
2286
|
-
// Back-compat:
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
|
|
2291
|
-
|
|
3172
|
+
// Back-compat: every shim body Dflow has previously shipped counts as pristine,
|
|
3173
|
+
// so configure-agents regenerates it to the current wording (and, for CLAUDE.md,
|
|
3174
|
+
// drops the legacy @import already stripped above). Without this, each body
|
|
3175
|
+
// reword would strand the previous release's shims on the skip path.
|
|
3176
|
+
return FROZEN_SHIM_BODY_BUILDERS.some(
|
|
3177
|
+
(buildFrozenBody) => existing === normalizeShimForMatch(buildFrozenBody(relativePath))
|
|
3178
|
+
);
|
|
2292
3179
|
}
|
|
2293
3180
|
|
|
2294
3181
|
function buildCodexCommandTriggerSection(commandRegistry) {
|
|
@@ -2462,13 +3349,13 @@ function validateDflowCommandRegistryRow(command, seen) {
|
|
|
2462
3349
|
}
|
|
2463
3350
|
}
|
|
2464
3351
|
|
|
3352
|
+
// Greenfield: the five common rows then `events.md` and the ADR row — the same
|
|
3353
|
+
// seven paths, in the same order, that `test/upgrade-drift.mjs` pins.
|
|
3354
|
+
// Brownfield: the five common rows only.
|
|
2465
3355
|
function buildDeferredItems(edition) {
|
|
2466
3356
|
const deferred = [...DEFERRED_COMMON];
|
|
2467
3357
|
if (edition === 'greenfield') {
|
|
2468
|
-
deferred.
|
|
2469
|
-
relativePath: 'dflow/specs/domain/{context}/events.md',
|
|
2470
|
-
reason: 'Greenfield only, but still needs a real bounded context.'
|
|
2471
|
-
});
|
|
3358
|
+
deferred.push(...DEFERRED_GREENFIELD_ONLY);
|
|
2472
3359
|
}
|
|
2473
3360
|
return deferred;
|
|
2474
3361
|
}
|
|
@@ -2517,8 +3404,13 @@ function buildSubstitutionMap(cwd, answers) {
|
|
|
2517
3404
|
['{系統名稱}', systemName],
|
|
2518
3405
|
['{project-type}', answers.projectType],
|
|
2519
3406
|
['{edition}', answers.edition],
|
|
2520
|
-
|
|
2521
|
-
|
|
3407
|
+
// These two land in the guide's "## Project Context" table cells (their
|
|
3408
|
+
// only template use), so bare `|` in the free-text answers must be escaped
|
|
3409
|
+
// or the row gains phantom cells and inference later truncates the value —
|
|
3410
|
+
// escapeTableCell is the exact inverse of parseContextLine's unescape
|
|
3411
|
+
// (PROPOSAL-076 gate G2 round-trip fix).
|
|
3412
|
+
['{tech-stack-summary}', escapeTableCell(answers.techStackSummary)],
|
|
3413
|
+
['{migration-context}', escapeTableCell(answers.migrationContext)],
|
|
2522
3414
|
['{prose-language}', answers.proseLanguage],
|
|
2523
3415
|
['{dflow-version}', pkg.version],
|
|
2524
3416
|
['{Language}', extracted.language || '{Language}'],
|
|
@@ -2543,6 +3435,28 @@ function buildSubstitutionMap(cwd, answers) {
|
|
|
2543
3435
|
return map;
|
|
2544
3436
|
}
|
|
2545
3437
|
|
|
3438
|
+
// Every placeholder token `substitutePlaceholders` can replace, derived from the
|
|
3439
|
+
// map itself so it cannot go stale. Exported for the guard that keeps
|
|
3440
|
+
// marker-delimited canonical regions substitution-free: those regions are
|
|
3441
|
+
// compared and rewritten as RAW PACKAGED BYTES, so a substituted token inside
|
|
3442
|
+
// one makes a freshly-projected file differ from its own packaged source.
|
|
3443
|
+
// ⚠ The answers are deliberately irrelevant here. `p090-b3-y1` caught the first
|
|
3444
|
+
// version of that guard proving only what its fixed init answers happened to
|
|
3445
|
+
// substitute: `{ORM / persistence}` maps to ITSELF when no ORM is named, so it
|
|
3446
|
+
// survived projection unchanged and the byte-comparison passed over it — while
|
|
3447
|
+
// an adopter who does name an ORM would get exactly the reported defect.
|
|
3448
|
+
function placeholderTokens() {
|
|
3449
|
+
const probe = buildSubstitutionMap(process.cwd(), {
|
|
3450
|
+
techStackSummary: '',
|
|
3451
|
+
migrationContext: '',
|
|
3452
|
+
proseLanguage: '',
|
|
3453
|
+
projectType: '',
|
|
3454
|
+
edition: '',
|
|
3455
|
+
gitPolicy: null
|
|
3456
|
+
});
|
|
3457
|
+
return [...probe.keys()];
|
|
3458
|
+
}
|
|
3459
|
+
|
|
2546
3460
|
function substitutePlaceholders(content, substitution) {
|
|
2547
3461
|
let result = content;
|
|
2548
3462
|
for (const [placeholder, value] of substitution.entries()) {
|
|
@@ -2693,6 +3607,20 @@ function extractTestFramework(text) {
|
|
|
2693
3607
|
return null;
|
|
2694
3608
|
}
|
|
2695
3609
|
|
|
3610
|
+
// ⚠ THIS FAMILY DELIBERATELY DOES NOT GO THROUGH `doctorChecks.classifyLines`,
|
|
3611
|
+
// and the boundary is worth stating because the rest of this file just moved the
|
|
3612
|
+
// other way (see `projectContextSectionBounds`). `ensureProseLanguageSection`,
|
|
3613
|
+
// `stripProseLanguageSections` and `stripNamedSections` only ever see content
|
|
3614
|
+
// this process generated moments earlier — `readPackagedTemplate` followed by
|
|
3615
|
+
// `substitutePlaceholders`, at the single call site in `addTemplate` — so the
|
|
3616
|
+
// heading shapes they must handle are the ones the packaged templates contain,
|
|
3617
|
+
// not the ones an adopter might type. They also fail LOUDLY on a mis-parse: the
|
|
3618
|
+
// `count !== 1` assertion below throws rather than writing a wrong file.
|
|
3619
|
+
//
|
|
3620
|
+
// The functions that DID move are the ones that read USER-OWNED files and ask
|
|
3621
|
+
// the same questions doctor asks. That is the line: parse a file someone else
|
|
3622
|
+
// wrote, use the shared classification; reshape a string you just rendered
|
|
3623
|
+
// yourself, a literal match is honest and the assertion covers it.
|
|
2696
3624
|
function ensureProseLanguageSection(content, proseLanguage) {
|
|
2697
3625
|
const section = buildProseLanguageSection(proseLanguage);
|
|
2698
3626
|
let stripped = stripProseLanguageSections(content);
|
|
@@ -2861,10 +3789,28 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2861
3789
|
warnings: []
|
|
2862
3790
|
};
|
|
2863
3791
|
|
|
3792
|
+
// PROPOSAL-058: an item flagged requiresFullApply (the `> Dflow Version:`
|
|
3793
|
+
// last-reconciled advance) may only run when every previewed change actually
|
|
3794
|
+
// applied. Guarded skips — changed-after-preview, vanished / non-file
|
|
3795
|
+
// targets, unexpected existing targets — mean the previewed reconciliation
|
|
3796
|
+
// is incomplete, so the flagged item is skipped with a warning instead of
|
|
3797
|
+
// overstating the reconciled version. Intentional skips (already current /
|
|
3798
|
+
// already configured) do not block it, and neither does removing a stale
|
|
3799
|
+
// file that is already gone (the desired end state holds).
|
|
3800
|
+
let unexpectedSkip = false;
|
|
3801
|
+
|
|
2864
3802
|
for (const item of plan.items) {
|
|
2865
3803
|
const targetPath = path.join(cwd, item.relativePath);
|
|
2866
3804
|
|
|
2867
3805
|
try {
|
|
3806
|
+
if (item.requiresFullApply && unexpectedSkip) {
|
|
3807
|
+
result.skipped.push(item.relativePath);
|
|
3808
|
+
result.warnings.push(
|
|
3809
|
+
`Skipped the Dflow Version update in ${item.relativePath} because earlier planned changes were skipped after the preview; re-run \`dflow configure-agents\`.`
|
|
3810
|
+
);
|
|
3811
|
+
continue;
|
|
3812
|
+
}
|
|
3813
|
+
|
|
2868
3814
|
if (item.action === 'remove') {
|
|
2869
3815
|
let stats;
|
|
2870
3816
|
try {
|
|
@@ -2879,6 +3825,7 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2879
3825
|
}
|
|
2880
3826
|
|
|
2881
3827
|
if (!stats.isFile()) {
|
|
3828
|
+
unexpectedSkip = true;
|
|
2882
3829
|
result.skipped.push(item.relativePath);
|
|
2883
3830
|
result.warnings.push(`Skipped stale removal because target is not a file: ${item.relativePath}`);
|
|
2884
3831
|
continue;
|
|
@@ -2886,6 +3833,7 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2886
3833
|
|
|
2887
3834
|
const currentContent = await fs.readFile(targetPath, 'utf8');
|
|
2888
3835
|
if (normalizeCommandAdapterFingerprint(currentContent) !== normalizeCommandAdapterFingerprint(item.expectedContent || '')) {
|
|
3836
|
+
unexpectedSkip = true;
|
|
2889
3837
|
result.skipped.push(item.relativePath);
|
|
2890
3838
|
result.warnings.push(`Skipped stale removal because content changed after preview: ${item.relativePath}`);
|
|
2891
3839
|
continue;
|
|
@@ -2908,6 +3856,7 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2908
3856
|
stats = await fs.stat(targetPath);
|
|
2909
3857
|
} catch (error) {
|
|
2910
3858
|
if (error.code === 'ENOENT') {
|
|
3859
|
+
unexpectedSkip = true;
|
|
2911
3860
|
result.skipped.push(item.relativePath);
|
|
2912
3861
|
result.warnings.push(`Skipped Dflow block update because ${item.relativePath} no longer exists; re-run to inject the Dflow block.`);
|
|
2913
3862
|
continue;
|
|
@@ -2915,12 +3864,14 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2915
3864
|
throw error;
|
|
2916
3865
|
}
|
|
2917
3866
|
if (!stats.isFile()) {
|
|
3867
|
+
unexpectedSkip = true;
|
|
2918
3868
|
result.skipped.push(item.relativePath);
|
|
2919
3869
|
result.warnings.push(`Skipped Dflow block update because ${item.relativePath} is no longer a regular file; re-run to inject the Dflow block.`);
|
|
2920
3870
|
continue;
|
|
2921
3871
|
}
|
|
2922
3872
|
const currentRaw = await fs.readFile(targetPath, 'utf8');
|
|
2923
3873
|
if (currentRaw !== item.expectedContent) {
|
|
3874
|
+
unexpectedSkip = true;
|
|
2924
3875
|
result.skipped.push(item.relativePath);
|
|
2925
3876
|
result.warnings.push(`Skipped Dflow block update because ${item.relativePath} changed after the preview; re-run to inject the Dflow block.`);
|
|
2926
3877
|
continue;
|
|
@@ -2948,6 +3899,7 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2948
3899
|
// already-current Dflow block) is expected, not a problem — don't emit the
|
|
2949
3900
|
// generic "skipped existing target" warning for it.
|
|
2950
3901
|
if (!item.intentionalSkip) {
|
|
3902
|
+
unexpectedSkip = true;
|
|
2951
3903
|
result.warnings.push(`Skipped existing target: ${item.relativePath}`);
|
|
2952
3904
|
}
|
|
2953
3905
|
if (item.relativePath === 'dflow/specs/shared/_conventions.md') {
|
|
@@ -2989,6 +3941,10 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2989
3941
|
}
|
|
2990
3942
|
|
|
2991
3943
|
if (targetExists) {
|
|
3944
|
+
// A previewed create raced against a concurrent creation — the
|
|
3945
|
+
// previewed change did not apply, so it blocks requiresFullApply
|
|
3946
|
+
// items exactly like the pre-write guard skips above.
|
|
3947
|
+
unexpectedSkip = true;
|
|
2992
3948
|
result.skipped.push(item.relativePath);
|
|
2993
3949
|
result.warnings.push(`Skipped existing target: ${item.relativePath}`);
|
|
2994
3950
|
continue;
|
|
@@ -3205,8 +4161,21 @@ async function runDoctor(options = {}) {
|
|
|
3205
4161
|
}
|
|
3206
4162
|
|
|
3207
4163
|
const findings = [];
|
|
4164
|
+
await checkConventionsParserUncertainty(cwd, findings);
|
|
3208
4165
|
await checkConventionsDflowVersion(cwd, findings);
|
|
3209
|
-
await
|
|
4166
|
+
await checkConventionsVersionReconciled(cwd, findings);
|
|
4167
|
+
await checkConventionsPolicyFormat(cwd, findings);
|
|
4168
|
+
await checkGuideCanonicalState(cwd, findings);
|
|
4169
|
+
await checkGuideProjectContextFormat(cwd, findings);
|
|
4170
|
+
await checkGuideSectionRefs(cwd, findings);
|
|
4171
|
+
await checkInitOnlyStarters(cwd, findings);
|
|
4172
|
+
await checkFeatureIndexShape(cwd, findings);
|
|
4173
|
+
await checkDocumentShapes(cwd, findings);
|
|
4174
|
+
await checkSpecTableConventionComment(cwd, findings);
|
|
4175
|
+
await checkRootAgentShims(cwd, findings);
|
|
4176
|
+
await checkAdapterAndSkillState(cwd, findings);
|
|
4177
|
+
await checkWorkflowBundleSourceAndOrphans(cwd, findings);
|
|
4178
|
+
await checkBundleManifestVersion(cwd, findings);
|
|
3210
4179
|
|
|
3211
4180
|
printDoctorReport(stdout, cwd, findings);
|
|
3212
4181
|
return 0;
|
|
@@ -3220,11 +4189,300 @@ async function runDoctor(options = {}) {
|
|
|
3220
4189
|
}
|
|
3221
4190
|
}
|
|
3222
4191
|
|
|
3223
|
-
|
|
4192
|
+
// FOUR doctor checks read `dflow/specs/shared/_conventions.md`, and each used to
|
|
4193
|
+
// decide separately what an unusable file was. They agreed about ABSENT (all
|
|
4194
|
+
// early-returned) and disagreed about BLANK: the drift check in
|
|
4195
|
+
// `checkInitOnlyStarters` deliberately collapses a blank file to one finding —
|
|
4196
|
+
// its comment says three fingerprint findings about a file with no content is
|
|
4197
|
+
// noise — while these three carried on and emitted four more. So the
|
|
4198
|
+
// suppression was defeated by its own siblings and a blank file produced five
|
|
4199
|
+
// findings, which `p082-b3-g1` finding 1 caught.
|
|
4200
|
+
//
|
|
4201
|
+
// ⚠ THE COUPLING THIS CREATES IS THE THING TO WATCH. Returning null here means
|
|
4202
|
+
// these three checks say NOTHING about an absent or blank file, and that is only
|
|
4203
|
+
// safe because `checkInitOnlyStarters` always runs and always reports it. Both
|
|
4204
|
+
// facts are load-bearing: `runDoctor` calls it unconditionally, and its
|
|
4205
|
+
// `conventionsAbsent || !conventions.trim()` branch has no early return in front
|
|
4206
|
+
// of it. Break either and the worst state of the file becomes silent everywhere
|
|
4207
|
+
// — the exact failure this subsystem exists to prevent. `test/upgrade-drift.mjs`
|
|
4208
|
+
// pins the count for absent and blank so the coupling cannot rot quietly.
|
|
4209
|
+
async function readConventionsForCheck(cwd) {
|
|
3224
4210
|
const conventionsPath = path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md');
|
|
3225
|
-
if (!(await pathExists(conventionsPath))) return;
|
|
4211
|
+
if (!(await pathExists(conventionsPath))) return null;
|
|
3226
4212
|
const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
|
|
3227
|
-
|
|
4213
|
+
return content.trim() ? content : null;
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
// PROPOSAL-084. Two `affects` sets, split because the gaps fail through two
|
|
4217
|
+
// different mechanisms and merging them would overstate one of them.
|
|
4218
|
+
//
|
|
4219
|
+
// VISIBILITY gaps change which TEXT counts as live, so everything that reads the
|
|
4220
|
+
// file through `visibleTextLines` is affected.
|
|
4221
|
+
const CONVENTIONS_VISIBILITY_AFFECTS = [
|
|
4222
|
+
'the `_conventions.md` convention-drift fingerprints',
|
|
4223
|
+
'whether the `## Git Policy` / `## AI Commit Policy` / `## Prose Language` sections are present, and the policy values read from them',
|
|
4224
|
+
// ⚠ ADDED after `p084-y1` finding 4 measured it: the starter checks are DOWNSTREAM
|
|
4225
|
+
// of the policy value, not a sibling of it. `We use trunk here <!-- Selected Git
|
|
4226
|
+
// policy: `gitflow` -->` makes `parseContextLine` return `gitflow`, which passes
|
|
4227
|
+
// the value check, and `checkInitOnlyStarters` then reports on
|
|
4228
|
+
// `Git-principles-gitflow.md` while saying nothing about the file the project
|
|
4229
|
+
// actually uses. Naming the value but not what the value drives understates the
|
|
4230
|
+
// blast radius, and this list is printed to users as measured fact.
|
|
4231
|
+
'the `Git-principles-*.md` starter checks, which are driven off the Git policy value',
|
|
4232
|
+
'dangling `§` references from `_conventions.md` into `AI-AGENT-GUIDE.md`'
|
|
4233
|
+
];
|
|
4234
|
+
// BOUNDARY gaps move where a SECTION ends. The policy VALUES are read line-wise
|
|
4235
|
+
// by `parseContextLine` and do not depend on section extent, so they are named as
|
|
4236
|
+
// unaffected rather than quietly folded in — an `affects` list is read as
|
|
4237
|
+
// measured, and padding it is the same false-precision this state exists to stop.
|
|
4238
|
+
const CONVENTIONS_BOUNDARY_AFFECTS = [
|
|
4239
|
+
'the `_conventions.md` convention-drift fingerprints',
|
|
4240
|
+
'whether the `## Git Policy` / `## AI Commit Policy` / `## Prose Language` sections are present (the policy VALUES are read line-wise and this shape does not affect them)'
|
|
4241
|
+
];
|
|
4242
|
+
|
|
4243
|
+
// ⚠ SCOPE: the two files whose CONTENT doctor makes claims about —
|
|
4244
|
+
// `_conventions.md` and `AI-AGENT-GUIDE.md` — and the boundary is measured rather
|
|
4245
|
+
// than cautious. Running these over the packaged tree finds the inline-comment
|
|
4246
|
+
// shape in both tracks' `phase-spec.md` templates, where `## Problem Description
|
|
4247
|
+
// <!-- Fill timing: ... -->` is a deliberate authoring convention, so widening the
|
|
4248
|
+
// scan to every spec file would make every project report uncertain forever —
|
|
4249
|
+
// which is how a disclosure becomes noise the user learns to skip. The two scanned
|
|
4250
|
+
// files are clean in every PACKAGED copy, so a fresh init is silent
|
|
4251
|
+
// (invariant 4).
|
|
4252
|
+
//
|
|
4253
|
+
// ⚠ The guide was EXCLUDED in the first draft, on the stated ground that its
|
|
4254
|
+
// false-positive cost "has not been measured". `p084-y1` finding 8 measured it —
|
|
4255
|
+
// 0 hits across all seven guides in this tree, for all four detectors — and
|
|
4256
|
+
// demonstrated the silent pass the exclusion was leaving open: a commented-out
|
|
4257
|
+
// value in the `## Project Context` table makes `parseContextLine` return the
|
|
4258
|
+
// comment text itself as the live value, `configure-agents` writes that into
|
|
4259
|
+
// generated prose, and a reader sees an empty cell. Once the reason for a boundary
|
|
4260
|
+
// is falsified the boundary goes, rather than the reason being rewritten to fit.
|
|
4261
|
+
const GUIDE_UNCERTAINTY_AFFECTS = [
|
|
4262
|
+
'the `## Project Context` values (`Tech stack`, `Migration / legacy context`) that `dflow configure-agents` reads back when it regenerates guide prose',
|
|
4263
|
+
'whether the `## Project Context` section is found at all, and the drift report about its rows',
|
|
4264
|
+
// ⚠ ADDED after `p084-xv1` measured the omission. The dangling-`§` check resolves
|
|
4265
|
+
// workflow-bundle references against THIS file's headings, via
|
|
4266
|
+
// `extractHeadings` -> the same classification a shape can disturb. Listing only
|
|
4267
|
+
// the Project Context concerns told the user the rest of the guide's checks were
|
|
4268
|
+
// still trustworthy, which is the precise false reassurance an `affects` list
|
|
4269
|
+
// exists to prevent.
|
|
4270
|
+
'whether `AI-AGENT-GUIDE.md § ...` references from the workflow bundle resolve, since that check reads this file\'s headings'
|
|
4271
|
+
];
|
|
4272
|
+
// ⚠⚠ THE `<textarea>` MITIGATION, SPELLED ONCE AND PREFIXED TO BOTH COMMENT IDS.
|
|
4273
|
+
// A `<textarea>` comment is reported deliberately (see the block comment above
|
|
4274
|
+
// `THERE IS NO TEXTAREA EXEMPTION HERE` in `doctor-checks` — grep that phrase — for
|
|
4275
|
+
// why the exemption was removed rather than patched a fourth time), so the *only* thing standing between the user and an edit
|
|
4276
|
+
// that hides their visible text is this sentence.
|
|
4277
|
+
// ⚠ It has to be on BOTH ids, and putting it on one was a real defect
|
|
4278
|
+
// (`p084-xv11` finding 1): `<textarea><!-- rule --></textarea>` on ONE line does not
|
|
4279
|
+
// start its line's content, so the partition correctly calls it `inline-html-comment`
|
|
4280
|
+
// — and that id's action says "move the comment to a line of its own at column 0",
|
|
4281
|
+
// which is exactly the edit that turns displayed raw text into a hidden comment.
|
|
4282
|
+
// The multi-line form routes to `comment-inside-container` and was safe. One shape,
|
|
4283
|
+
// two ids, one of them unprotected.
|
|
4284
|
+
// ⚠ Spelled once and shared, not copied into both strings: two copies of a rule is
|
|
4285
|
+
// this codebase's oldest defect class, and this rule has already been rewritten
|
|
4286
|
+
// three times. The first-hit warning matters because each detector emits at most one
|
|
4287
|
+
// finding per file: a harmless textarea hit can shadow a later, genuinely hidden hit.
|
|
4288
|
+
const TEXTAREA_LEAVE_IT_ALONE = '⚠ FIRST, if the cited comment sits inside a `<textarea>`: leave that line alone. A `<textarea>` holds raw text, so moving it out would hide text the reader can already see. But do NOT ignore the overall uncertainty result or treat the affected checks as trusted: Dflow reports only the first occurrence of each shape in a file, so this harmless line can precede and shadow a genuinely hidden comment with the same id. Inspect the rest of the cited file for other apparent comment openers before treating those checks as reliable. For a non-`<textarea>` occurrence: ';
|
|
4289
|
+
// ⚠ This scope is part of the repair contract. The markerless continuation case
|
|
4290
|
+
// deliberately follows the renderer Dflow ships; CommonMark-family renderers can
|
|
4291
|
+
// expose the apparent opener instead, so universal "the reader sees nothing" prose
|
|
4292
|
+
// would send some users to edit content their actual publishing path displays.
|
|
4293
|
+
const MARKED_COMMENT_CALIBRATION = '⚠ A markerless continuation of list-owned raw HTML is calibrated to the renderer Dflow ships (`dflow render`, powered by Marked). Another Markdown renderer may expose an escaped apparent opener there; if you publish through a different renderer, inspect its rendered output before applying this repair. ';
|
|
4294
|
+
const CONVENTIONS_UNCERTAINTY_DETECTORS = [
|
|
4295
|
+
{
|
|
4296
|
+
id: 'inline-html-comment',
|
|
4297
|
+
title: 'an HTML comment begins part-way through a line',
|
|
4298
|
+
locate: (content) => doctorChecks.inlineHtmlCommentLine(content),
|
|
4299
|
+
detail: 'Dflow classifies Markdown one line at a time, so a comment that opens mid-line is not a block to it and its contents are counted as live document text — while `dflow render` normally shows no comment text there. A rule, policy value or heading word sitting inside such a comment therefore reads as still present after it has been switched off.',
|
|
4300
|
+
// ⚠⚠ THIS ACTION USED TO SEND THE USER INTO AN UNDISCLOSED SILENT PASS
|
|
4301
|
+
// (`p084-y1` finding 1). It said "move the comment onto a line of its own",
|
|
4302
|
+
// full stop — and the natural way to do that inside a list item is to indent
|
|
4303
|
+
// it under the item, where NOTHING fires and the text is still read. The
|
|
4304
|
+
// column matters, so the column is now what the sentence says.
|
|
4305
|
+
action: TEXTAREA_LEAVE_IT_ALONE + MARKED_COMMENT_CALIBRATION + 'move the comment to a line of its own that starts at column 0, outside any CONTAINER — a list item, a block quote, or an HTML block such as `<details>`. There it opens a real HTML block of its own and its contents stop being read. Deleting it works too. ⚠ Indenting it under a list item is NOT enough: it stays inside the item, where Dflow still reads it. ⚠ And if the comment is inside an HTML block, column 0 alone is NOT enough either — you are still inside the block, and `comment-inside-container` will report it on the next run. The explainer page section for that id has the per-tag rule for leaving an HTML block.',
|
|
4306
|
+
affects: CONVENTIONS_VISIBILITY_AFFECTS
|
|
4307
|
+
},
|
|
4308
|
+
{
|
|
4309
|
+
id: 'comment-inside-container',
|
|
4310
|
+
title: 'an HTML comment sits inside a container whose interior Dflow does not parse, where its text is still read',
|
|
4311
|
+
// ⚠ REPLACES `unclosed-html-in-container`, which asked the wrong question.
|
|
4312
|
+
// That one fired on an UNTERMINATED comment the document-level scan had
|
|
4313
|
+
// missed; measured, any later `-->` in the file silenced it — including the
|
|
4314
|
+
// one `unclosed-html-block`'s own action tells the user to add — while the
|
|
4315
|
+
// hidden rule stayed hidden. Termination was never the issue: a properly
|
|
4316
|
+
// closed comment inside a list item hides its text from a reader just as
|
|
4317
|
+
// completely, and Dflow reads it either way.
|
|
4318
|
+
locate: (content) => doctorChecks.containerHtmlCommentLine(content),
|
|
4319
|
+
detail: 'Dflow does not parse the interior of a container as its own sequence of blocks — a list item and a block quote are the common cases, and an HTML block such as `<details>` behaves the same way. A comment opened inside one therefore never opens an HTML block as far as Dflow is concerned: `dflow render` normally shows no comment text there, while Dflow goes on counting the comment\'s contents as live document text. This holds whether or not the comment is closed, and whatever the container turns out to be.',
|
|
4320
|
+
// ⚠⚠ THE HTML-BLOCK HALF NAMES THE TAG, and a blanket rule here was wrong for
|
|
4321
|
+
// two of them (`p084-xv3`). "A blank line ends the block, a closing tag does
|
|
4322
|
+
// not" is CommonMark's TYPE-6 rule. `<pre>` and `<textarea>` are type 1: they
|
|
4323
|
+
// end at their own closing tag, and they reach this id — measured, a comment
|
|
4324
|
+
// inside `<pre>` reports, adding the blank line the old sentence prescribed
|
|
4325
|
+
// does NOT clear it, and moving below `</pre>` (which the old sentence said
|
|
4326
|
+
// would not work) does. So the shipped repair was exactly backwards for them.
|
|
4327
|
+
// `<script>` and `<style>` are type 1 too but never reach here — their
|
|
4328
|
+
// interiors are already invisible, so nothing is hidden from a reader that was
|
|
4329
|
+
// not hidden anyway — which is why they are not named.
|
|
4330
|
+
// ⚠⚠ `<textarea>` WAS named here as a repair case for one round and that was
|
|
4331
|
+
// wrong twice over (`p084-xv4` finding 1): it holds raw text, so the comment is
|
|
4332
|
+
// displayed and there is nothing to disclose, and the advice given for it —
|
|
4333
|
+
// move below the closing tag — is the one edit that would genuinely hide it.
|
|
4334
|
+
// ⚠⚠⚠ EVERY `<textarea>` COMMENT IS NOW REPORTED, and the leading clause in the
|
|
4335
|
+
// action is the whole mitigation. Three rounds were spent suppressing that false
|
|
4336
|
+
// positive and each attempt produced a SILENT PASS instead (`p084-xv5`, `xv9`,
|
|
4337
|
+
// `xv10`); the exemption was removed rather than patched a fourth time. The full
|
|
4338
|
+
// account is under `THERE IS NO TEXTAREA EXEMPTION HERE` in `doctor-checks`.
|
|
4339
|
+
// Reporting is the NOISY direction, which the maintainer twice chose in this same
|
|
4340
|
+
// area (2026-08-09). **Do not "tidy" the `<textarea>` clause away** — without it
|
|
4341
|
+
// the finding sends a user to make the one edit that genuinely hides their text.
|
|
4342
|
+
// ⚠ The outermost-container sentence is not decoration either: with a `<pre>`
|
|
4343
|
+
// inside a list item, applying only the `<pre>` rule leaves the comment in the
|
|
4344
|
+
// list item and the finding fires again on the next run.
|
|
4345
|
+
// ⚠ Concatenation, not a template literal: this string carries backticked code
|
|
4346
|
+
// spans (`<pre>`, `>`), which end a template literal on the spot.
|
|
4347
|
+
action: TEXTAREA_LEAVE_IT_ALONE + MARKED_COMMENT_CALIBRATION + 'move the comment out of the enclosing container — or delete it. Leaving the container is the whole repair. ⚠ When the comment sits inside MORE THAN ONE container — a `<pre>` inside a list item, a `<details>` inside a block quote — the OUTERMOST one is the one you have to leave: repair only the inner one and the finding comes back unchanged. For a list item or block quote, put the comment on a line of its own at column 0 with no list marker or `>` before it. For an HTML block the rule depends on the tag — `<pre>` ends at its own `</pre>`, so move the comment below that; every other block (`<details>`, `<div>`, …) ends at a BLANK LINE and not at a closing tag, so put a blank line between the block and the comment, or move the comment above the block entirely. Re-indenting the COMMENT inside the container never helps. (One thing that does help, and only for the fenced-example case the explainer page describes: un-indenting the FENCE itself to three spaces or fewer.)',
|
|
4348
|
+
affects: CONVENTIONS_VISIBILITY_AFFECTS
|
|
4349
|
+
},
|
|
4350
|
+
{
|
|
4351
|
+
id: 'html-block-type-7',
|
|
4352
|
+
title: 'a bare custom tag stands at the start of a block, directly above a `---` or `===` line',
|
|
4353
|
+
locate: (content) => doctorChecks.htmlBlockType7Line(content),
|
|
4354
|
+
detail: 'A complete tag whose name is not one of CommonMark\'s known block tags, alone on a line, is HTML block type 7 — the only type that cannot interrupt a paragraph, and the only one that needs a real tag parser to recognise. Dflow does not implement it, so where this shape meets a following underline it ends the section earlier than a renderer does. This one is usually loud — it reports drift that is not there — but it is NOT only loud: ending the section early also drops the tail of it, so a retired row below the shape stops being seen and its finding disappears. Treat results about that section as unknown in both directions.',
|
|
4355
|
+
action: 'Put a blank line between the tag and the underline, or fence the tag as an example if it is being shown rather than used.',
|
|
4356
|
+
affects: CONVENTIONS_BOUNDARY_AFFECTS
|
|
4357
|
+
}
|
|
4358
|
+
];
|
|
4359
|
+
|
|
4360
|
+
// ⚠⚠ `table-delimiter-cell-count` USED TO BE THE FIFTH ENTRY HERE, AND ITS REMOVAL
|
|
4361
|
+
// IS A DECISION, NOT AN OVERSIGHT (user, 2026-08-12). It reported a table whose
|
|
4362
|
+
// delimiter row carried a different number of cells from its header, on the
|
|
4363
|
+
// grounds that Dflow and the renderer can then disagree about where the section
|
|
4364
|
+
// ends. That harm is real and is still real — it is recorded, with owner and
|
|
4365
|
+
// gate, as `doctor-section-boundary-arbiter` in `planning/opt-in-backlog.md`.
|
|
4366
|
+
// What could not be made to work was the DETECTOR. Six consecutive review rounds
|
|
4367
|
+
// each found a document it stayed silent on, and silence here is the direction
|
|
4368
|
+
// that prints `All checks passed` over a drifted file:
|
|
4369
|
+
// `x7` bare pair, `y3` multi-line dash, `y4` equals family, `x13`/`y5`
|
|
4370
|
+
// underline-shaped first prose line, `x14`/`y6` indented first prose line,
|
|
4371
|
+
// `y7` single-hyphen delimiter row.
|
|
4372
|
+
// Narrowing it to measured shapes failed five times; widening it back to every
|
|
4373
|
+
// mismatch failed on the sixth, because the remaining enumeration had moved into
|
|
4374
|
+
// the delimiter-row recogniser. And `y6` measured the same silent false clean
|
|
4375
|
+
// with a delimiter row whose cell count was CORRECT — so this detector's scope
|
|
4376
|
+
// was a strict subset of the harm and no version of it could close it.
|
|
4377
|
+
// ⚠ The durable fix is a different instrument, not a better shape list: `marked`
|
|
4378
|
+
// is already a runtime dependency, so the section boundary can be taken FROM the
|
|
4379
|
+
// renderer instead of guessed alongside it. That is a design change with its own
|
|
4380
|
+
// review, which is why this ships as a stated gap instead of a sixth repair.
|
|
4381
|
+
// Both explainer pages name it under "shapes that are known and deliberately not
|
|
4382
|
+
// reported", beside the table-indent gap it now sits next to.
|
|
4383
|
+
|
|
4384
|
+
|
|
4385
|
+
// The document-level unclosed block is a condition on the whole file rather than
|
|
4386
|
+
// one of the narrower shape detectors. It still runs through the same two-file
|
|
4387
|
+
// target loop: both `_conventions.md` and `AI-AGENT-GUIDE.md` feed the classifier.
|
|
4388
|
+
const UNCLOSED_HTML_BLOCK_ID = 'unclosed-html-block';
|
|
4389
|
+
|
|
4390
|
+
function unclosedHtmlBlockUncertainty(rel, line, affects) {
|
|
4391
|
+
return uncertainFinding({
|
|
4392
|
+
id: UNCLOSED_HTML_BLOCK_ID,
|
|
4393
|
+
title: `${rel} has an unclosed HTML block at line ${line}`,
|
|
4394
|
+
// BOTH DIRECTIONS: text below the opener can produce artefact findings, and
|
|
4395
|
+
// genuine drift can disappear because the checks no longer see that text.
|
|
4396
|
+
detail: `Everything from that line to the end of the file is inside that block, so it is not ordinary document content and Dflow cannot assess results below it. This cuts both ways: a finding below this one may be caused by the unclosed block rather than by real drift, AND a rule that genuinely has drifted below it can go unreported entirely, because the block hides the text the check would have read. Treat every result about content below this line as unknown — not as passing.`,
|
|
4397
|
+
affects,
|
|
4398
|
+
action: 'Close the block (for an HTML comment, add `-->`), then re-run `dflow doctor`.'
|
|
4399
|
+
});
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4402
|
+
// ⚠ Every id doctor can print, in one place, because each one is a PROMISE that
|
|
4403
|
+
// `docs/doctor-uncertainty.md` / `.en.md` carries a section explaining that
|
|
4404
|
+
// shape. A shipped message pointing at a page section that does not exist is
|
|
4405
|
+
// precisely the failure this proposal was created to retire — the durable fix was
|
|
4406
|
+
// moving the per-container explanation onto a page that can be revised, and that
|
|
4407
|
+
// only works while the id and the section agree. `test/upgrade-drift.mjs` asserts
|
|
4408
|
+
// the correspondence in both directions against both languages.
|
|
4409
|
+
// PROPOSAL-092 D4: a spec doc whose `<!-- dflow-shape: … -->` line cannot be read.
|
|
4410
|
+
// Emitted by `checkDocumentShapes`, not by the two-file loop below.
|
|
4411
|
+
const SHAPE_MARKER_UNREADABLE_ID = 'unreadable-shape-marker';
|
|
4412
|
+
|
|
4413
|
+
const DOCTOR_UNCERTAINTY_IDS = [
|
|
4414
|
+
UNCLOSED_HTML_BLOCK_ID,
|
|
4415
|
+
...CONVENTIONS_UNCERTAINTY_DETECTORS.map((d) => d.id),
|
|
4416
|
+
SHAPE_MARKER_UNREADABLE_ID
|
|
4417
|
+
];
|
|
4418
|
+
|
|
4419
|
+
// PROPOSAL-084: report the shapes this module is known to read unreliably, so a
|
|
4420
|
+
// project containing one never receives the same `All checks passed` a genuinely
|
|
4421
|
+
// clean project gets. Reads the same guarded content the sibling checks read, so
|
|
4422
|
+
// an absent or blank file stays the drift check's single finding rather than
|
|
4423
|
+
// picking up four more (the coupling described above `readConventionsForCheck`).
|
|
4424
|
+
async function checkConventionsParserUncertainty(cwd, findings) {
|
|
4425
|
+
// ⚠⚠ PUSH ORDER HERE DOES NOT DECIDE PRINT ORDER, and two review rounds reached
|
|
4426
|
+
// OPPOSITE conclusions about that, so the measurement is recorded rather than
|
|
4427
|
+
// the argument. `unclosed-html-block` moved into this loop, and the comment that
|
|
4428
|
+
// moved with it claimed the finding had to be emitted BEFORE the drift findings
|
|
4429
|
+
// "because it changes what they mean". `p084gate-y5` read the relocation as
|
|
4430
|
+
// having reversed a deliberate contract and filed it as a must-fix regression;
|
|
4431
|
+
// `p084gate-y4` had already called the same deletion a correction.
|
|
4432
|
+
// Measured 2026-08-12 on a project carrying BOTH a retired rule row and an
|
|
4433
|
+
// unclosed block, run through the real CLI on this tree and on the tree before
|
|
4434
|
+
// this batch: **identical output order in both** — `[warn]` first, `[uncertain]`
|
|
4435
|
+
// fourth lines later. `runDoctor` groups by category and prints assessed before
|
|
4436
|
+
// uncertain regardless of the order anything is pushed here, and did so before
|
|
4437
|
+
// this batch too. So the contract the old comment stated was never honoured by
|
|
4438
|
+
// the printer, the relocation changed nothing about it, and there is no
|
|
4439
|
+
// regression to repair. If you want that ordering, it has to be implemented in
|
|
4440
|
+
// the printer — a comment here cannot buy it.
|
|
4441
|
+
//
|
|
4442
|
+
// ⚠ Same detectors, two files, DIFFERENT `affects` — the shape is a property of
|
|
4443
|
+
// the Markdown, but which checks it blinds is a property of the file. Sharing
|
|
4444
|
+
// one list across both would put a claim about `_conventions.md`'s drift
|
|
4445
|
+
// fingerprints into a finding about the guide.
|
|
4446
|
+
const targets = [
|
|
4447
|
+
{
|
|
4448
|
+
rel: 'dflow/specs/shared/_conventions.md',
|
|
4449
|
+
content: await readConventionsForCheck(cwd),
|
|
4450
|
+
affects: null // per-detector; the conventions lists distinguish visibility from boundary
|
|
4451
|
+
},
|
|
4452
|
+
{
|
|
4453
|
+
rel: AI_AGENT_GUIDE_DEST,
|
|
4454
|
+
content: (await fs.readFile(path.join(cwd, AI_AGENT_GUIDE_DEST), 'utf8').catch(() => '')) || null,
|
|
4455
|
+
affects: GUIDE_UNCERTAINTY_AFFECTS
|
|
4456
|
+
}
|
|
4457
|
+
];
|
|
4458
|
+
for (const target of targets) {
|
|
4459
|
+
if (target.content === null || !target.content.trim()) continue;
|
|
4460
|
+
const unclosedAt = doctorChecks.unclosedHtmlBlockLine(target.content);
|
|
4461
|
+
if (unclosedAt !== -1) {
|
|
4462
|
+
findings.push(unclosedHtmlBlockUncertainty(
|
|
4463
|
+
target.rel,
|
|
4464
|
+
unclosedAt,
|
|
4465
|
+
target.affects || CONVENTIONS_VISIBILITY_AFFECTS
|
|
4466
|
+
));
|
|
4467
|
+
}
|
|
4468
|
+
for (const detector of CONVENTIONS_UNCERTAINTY_DETECTORS) {
|
|
4469
|
+
const line = detector.locate(target.content);
|
|
4470
|
+
if (line === -1) continue;
|
|
4471
|
+
findings.push(uncertainFinding({
|
|
4472
|
+
id: detector.id,
|
|
4473
|
+
title: `${target.rel}, line ${line}: ${detector.title}`,
|
|
4474
|
+
detail: detector.detail,
|
|
4475
|
+
affects: target.affects || detector.affects,
|
|
4476
|
+
action: detector.action
|
|
4477
|
+
}));
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
async function checkConventionsDflowVersion(cwd, findings) {
|
|
4483
|
+
const content = await readConventionsForCheck(cwd);
|
|
4484
|
+
if (content === null) return;
|
|
4485
|
+
if (!/^> Dflow Version:/m.test(content)) {
|
|
3228
4486
|
findings.push({
|
|
3229
4487
|
level: 'info',
|
|
3230
4488
|
title: 'dflow/specs/shared/_conventions.md missing Dflow Version line',
|
|
@@ -3234,6 +4492,1922 @@ async function checkConventionsDflowVersion(cwd, findings) {
|
|
|
3234
4492
|
}
|
|
3235
4493
|
}
|
|
3236
4494
|
|
|
4495
|
+
// PROPOSAL-058 (user decision 2026-06-08, OQ2): `> Dflow Version:` records the
|
|
4496
|
+
// Dflow version this project last reconciled with (`dflow configure-agents`
|
|
4497
|
+
// advances it). Behind the CLI means the Dflow-managed layers may be stale and
|
|
4498
|
+
// the user-owned layers unreviewed since the upgrade.
|
|
4499
|
+
async function checkConventionsVersionReconciled(cwd, findings) {
|
|
4500
|
+
const content = await readConventionsForCheck(cwd);
|
|
4501
|
+
if (content === null) return;
|
|
4502
|
+
const match = content.match(/^> Dflow Version:[ \t]*(.*)$/m);
|
|
4503
|
+
if (!match) return; // absence is checkConventionsDflowVersion's finding
|
|
4504
|
+
const recorded = match[1].trim();
|
|
4505
|
+
// Prerelease suffixes are valid package versions (the smoke test's own
|
|
4506
|
+
// Dflow-Version assertion allows them), so they must not read as "not a
|
|
4507
|
+
// version" noise on a fresh init of a prerelease build.
|
|
4508
|
+
if (!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(recorded)) {
|
|
4509
|
+
findings.push({
|
|
4510
|
+
level: 'info',
|
|
4511
|
+
title: `_conventions.md Dflow Version line is not a plain x.y.z version: \`${recorded}\``,
|
|
4512
|
+
detail: 'The line records which Dflow version the project last reconciled with; doctor cannot compare this value against the CLI.',
|
|
4513
|
+
action: `Set it to the Dflow version the project is actually aligned to; \`dflow configure-agents\` keeps it current from then on (CLI is ${pkg.version}).`
|
|
4514
|
+
});
|
|
4515
|
+
return;
|
|
4516
|
+
}
|
|
4517
|
+
if (recorded === pkg.version) return;
|
|
4518
|
+
if (compareVersions(recorded, pkg.version) < 0) {
|
|
4519
|
+
findings.push({
|
|
4520
|
+
level: 'info',
|
|
4521
|
+
title: `Project last reconciled with Dflow ${recorded}; this CLI is ${pkg.version}`,
|
|
4522
|
+
detail: 'Dflow-managed layers (workflow bundle, guide canonical content, adapters) may be stale, and user-owned layers may need review against the newer version.',
|
|
4523
|
+
action: 'Run `dflow configure-agents` to re-project the Dflow-managed layers and update the line, then review the upgrade guide for user-owned surfaces: https://github.com/weilung/dflow-sdd-ddd/blob/main/docs/upgrading.en.md (offline copy: docs/upgrading.en.md in the installed package).'
|
|
4524
|
+
});
|
|
4525
|
+
} else {
|
|
4526
|
+
findings.push({
|
|
4527
|
+
level: 'info',
|
|
4528
|
+
title: `Project was reconciled with Dflow ${recorded}, newer than this CLI (${pkg.version})`,
|
|
4529
|
+
detail: 'Running an older CLI against a newer project layout can re-project older content over newer files.',
|
|
4530
|
+
action: 'Upgrade the dflow package before re-running `dflow init` / `dflow configure-agents` here.'
|
|
4531
|
+
});
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
// PROPOSAL-058 direction 2 (b): the policy sections are machine-read — context
|
|
4536
|
+
// inference for configure-agents parses the exact line formats in
|
|
4537
|
+
// lib/doctor-checks.js. A section that drifted from the canonical format makes
|
|
4538
|
+
// inference return null, and code paths that need a policy default a null to
|
|
4539
|
+
// `trunk` / `none`, so drift here risks a silent policy flip on any future
|
|
4540
|
+
// re-projection that writes these sections.
|
|
4541
|
+
async function checkConventionsPolicyFormat(cwd, findings) {
|
|
4542
|
+
const content = await readConventionsForCheck(cwd);
|
|
4543
|
+
if (content === null) return;
|
|
4544
|
+
|
|
4545
|
+
const sections = [
|
|
4546
|
+
{
|
|
4547
|
+
heading: '## Git Policy',
|
|
4548
|
+
key: 'Git Policy',
|
|
4549
|
+
re: doctorChecks.GIT_POLICY_LINE_RE,
|
|
4550
|
+
values: doctorChecks.GIT_POLICY_VALUES,
|
|
4551
|
+
example: 'Selected Git policy: `gitflow`',
|
|
4552
|
+
// ⚠ "defaults to `trunk` wherever a policy value is required" was too
|
|
4553
|
+
// broad, and the gate-1 disclosure put the overstatement next to its own
|
|
4554
|
+
// contradiction in the same report. Verified by running both commands:
|
|
4555
|
+
// `init` does fall back to trunk (`buildFilePlan`), but `configure-agents`
|
|
4556
|
+
// does NOT — `addGitPrinciplesItem` reports the missing section and
|
|
4557
|
+
// returns without touching any Git principles file. Say which is which.
|
|
4558
|
+
nullEffect: 'a null Git policy falls back to `trunk` where init needs a value, while `dflow configure-agents` instead declines to refresh `Git-principles-*.md` at all'
|
|
4559
|
+
},
|
|
4560
|
+
{
|
|
4561
|
+
heading: '## AI Commit Policy',
|
|
4562
|
+
key: 'AI Commit Policy',
|
|
4563
|
+
re: doctorChecks.AI_COMMIT_MARKER_LINE_RE,
|
|
4564
|
+
values: doctorChecks.AI_COMMIT_MARKER_VALUES,
|
|
4565
|
+
example: 'AI commit marker: `none`',
|
|
4566
|
+
nullEffect: 'an unrecognized marker value falls back to `none`'
|
|
4567
|
+
},
|
|
4568
|
+
{
|
|
4569
|
+
heading: '## Prose Language',
|
|
4570
|
+
key: 'Prose Language',
|
|
4571
|
+
re: doctorChecks.PROSE_LANGUAGE_LINE_RE,
|
|
4572
|
+
values: null,
|
|
4573
|
+
example: 'Project prose language: `en`',
|
|
4574
|
+
nullEffect: 'prose-generating flows lose the project language setting'
|
|
4575
|
+
}
|
|
4576
|
+
];
|
|
4577
|
+
|
|
4578
|
+
// Locate the section through the SAME recognizer the drift check uses, not a
|
|
4579
|
+
// bespoke `^## X$`. That regex demanded an unindented heading of exactly
|
|
4580
|
+
// level 2, while `doctor-checks.headingAt` follows CommonMark and accepts a
|
|
4581
|
+
// 1-3 space indent — so ` ## Git Policy` was reported missing by this check
|
|
4582
|
+
// and found by the other, on a file whose policy line `parseContextLine` read
|
|
4583
|
+
// perfectly well (`p082-b3-g1` finding 2).
|
|
4584
|
+
//
|
|
4585
|
+
// ⚠ This deliberately WIDENS what counts as present, including to other
|
|
4586
|
+
// heading levels, and that is not a loosening of the contract being enforced:
|
|
4587
|
+
// inference (`parseContextLine`) is a whole-file match that never looks at the
|
|
4588
|
+
// heading at all. The heading test is only a locator for "is this section
|
|
4589
|
+
// here", so demanding a level and an indent was stricter than the thing it
|
|
4590
|
+
// protects — it produced false "missing" reports, not extra safety.
|
|
4591
|
+
for (const section of sections) {
|
|
4592
|
+
if (doctorChecks.conventionsSectionBodies(content, section.key).length === 0) {
|
|
4593
|
+
findings.push({
|
|
4594
|
+
level: 'warn',
|
|
4595
|
+
title: `_conventions.md is missing the ${section.heading} section`,
|
|
4596
|
+
detail: 'Newer Dflow init projects always carry it; existing projects are not auto-migrated (user-owned file).',
|
|
4597
|
+
action: `Copy the section from a fresh \`dflow init\` project and set your value (canonical line: \`${section.example}\`).`
|
|
4598
|
+
});
|
|
4599
|
+
continue;
|
|
4600
|
+
}
|
|
4601
|
+
const value = doctorChecks.parseContextLine(content, section.re);
|
|
4602
|
+
if (value === null || (section.values && !section.values.has(value))) {
|
|
4603
|
+
findings.push({
|
|
4604
|
+
level: 'warn',
|
|
4605
|
+
title: `_conventions.md ${section.heading} line is not machine-readable`,
|
|
4606
|
+
detail: `Dflow parses a \`${section.example}\`-style line to infer project context; as written, inference returns null and ${section.nullEffect}.`,
|
|
4607
|
+
action: `Restore the canonical line format, e.g. \`${section.example}\`.`
|
|
4608
|
+
});
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4613
|
+
// PROPOSAL-058 direction 2 (a): guide manageability + canonical staleness. The
|
|
4614
|
+
// canonical region is substitution-free by design (a test guards that), so a
|
|
4615
|
+
// current projection equals the packaged region byte-for-byte after LF
|
|
4616
|
+
// normalization.
|
|
4617
|
+
async function checkGuideCanonicalState(cwd, findings) {
|
|
4618
|
+
const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
|
|
4619
|
+
if (!(await pathExists(guidePath))) {
|
|
4620
|
+
if (await pathExists(path.join(cwd, WORKFLOW_BUNDLE_DEST))) {
|
|
4621
|
+
findings.push({
|
|
4622
|
+
level: 'warn',
|
|
4623
|
+
title: `${AI_AGENT_GUIDE_DEST} is missing but the workflow bundle is projected`,
|
|
4624
|
+
detail: 'Bundle flow files § reference the guide; without it agents lose routing, ceremony, and transparency rules.',
|
|
4625
|
+
action: 'Run `dflow configure-agents` to project the guide.'
|
|
4626
|
+
});
|
|
4627
|
+
}
|
|
4628
|
+
return;
|
|
4629
|
+
}
|
|
4630
|
+
// ⚠⚠ PRESENT-BUT-UNREADABLE IS NOT EMPTY, AND THE OLD `.catch(() => '')` MADE
|
|
4631
|
+
// IT SO. An empty string classifies as `absent` markers and then fails the
|
|
4632
|
+
// recognizable-guide test, so a guide that is perfectly current — but locked
|
|
4633
|
+
// by permissions, or replaced by a directory — was reported as "not
|
|
4634
|
+
// recognizable as a Dflow guide", a verdict on bytes this function never read,
|
|
4635
|
+
// with an action telling the reader to REBUILD an intact file. The canonical
|
|
4636
|
+
// comparison also vanished in the same breath, unannounced.
|
|
4637
|
+
// ⚠ The remedy is already written twice in this file — `checkInitOnlyStarters`
|
|
4638
|
+
// splits `unreadable` out for this exact reason, and so does
|
|
4639
|
+
// `checkAdapterAndSkillState`. This was the third site, left as it was.
|
|
4640
|
+
let rawGuide = null;
|
|
4641
|
+
let guideReadError = null;
|
|
4642
|
+
try {
|
|
4643
|
+
rawGuide = await fs.readFile(guidePath, 'utf8');
|
|
4644
|
+
} catch (error) {
|
|
4645
|
+
guideReadError = error;
|
|
4646
|
+
}
|
|
4647
|
+
if (rawGuide === null) {
|
|
4648
|
+
findings.push({
|
|
4649
|
+
level: 'warn',
|
|
4650
|
+
title: `${AI_AGENT_GUIDE_DEST} could not be read`,
|
|
4651
|
+
detail: `It exists but this check could not read it (${guideReadError && (guideReadError.code || guideReadError.message)}), so nothing can be said about its markers or whether its canonical region is current.`,
|
|
4652
|
+
action: 'Check the path\'s permissions and that it is a file rather than a directory, then re-run `dflow doctor`.'
|
|
4653
|
+
});
|
|
4654
|
+
return;
|
|
4655
|
+
}
|
|
4656
|
+
const content = toLf(rawGuide);
|
|
4657
|
+
const region = classifyMarkedRegion(content, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
|
|
4658
|
+
if (region.state === 'malformed') {
|
|
4659
|
+
findings.push({
|
|
4660
|
+
level: 'warn',
|
|
4661
|
+
title: `${AI_AGENT_GUIDE_DEST} has malformed guide-canonical markers`,
|
|
4662
|
+
detail: '`dflow configure-agents` cannot locate the canonical region and will not refresh it.',
|
|
4663
|
+
action: 'Repair or remove the stray `<!-- dflow-generated: guide-canonical ... -->` markers, then re-run `dflow configure-agents`.'
|
|
4664
|
+
});
|
|
4665
|
+
return;
|
|
4666
|
+
}
|
|
4667
|
+
if (region.state === 'absent') {
|
|
4668
|
+
// Mirror the configure-agents bootstrap split: the adoption offer only
|
|
4669
|
+
// exists for a recognizable Dflow guide, so pointing an unrecognizable file
|
|
4670
|
+
// at the offer would be impossible advice.
|
|
4671
|
+
if (isRecognizableDflowGuide(content)) {
|
|
4672
|
+
findings.push({
|
|
4673
|
+
level: 'info',
|
|
4674
|
+
title: `${AI_AGENT_GUIDE_DEST} predates managed guide-canonical markers`,
|
|
4675
|
+
detail: 'Its canonical sections stay at the Dflow version that wrote them; upgrades cannot refresh them in place.',
|
|
4676
|
+
action: 'Re-run `dflow configure-agents` on an interactive terminal and accept the marker-adoption offer (your "## Project Context" is kept), or reconcile manually against a fresh `dflow init`.'
|
|
4677
|
+
});
|
|
4678
|
+
} else {
|
|
4679
|
+
findings.push({
|
|
4680
|
+
level: 'info',
|
|
4681
|
+
title: `${AI_AGENT_GUIDE_DEST} is not recognizable as a Dflow guide`,
|
|
4682
|
+
detail: 'It has no guide-canonical markers and lacks the Dflow guide shape (`# Dflow AI Agent Guide` title plus `## Project Context`), so `configure-agents` will not offer marker adoption and workflow-bundle § references may dangle.',
|
|
4683
|
+
action: 'If it should be Dflow-managed, rebuild it from a fresh `dflow init` comparison (carry your project notes over), or keep maintaining it yourself.'
|
|
4684
|
+
});
|
|
4685
|
+
}
|
|
4686
|
+
return;
|
|
4687
|
+
}
|
|
4688
|
+
// ⚠⚠ PROPOSAL-091 gates 4, 5 and 6 — this function carried all three of the
|
|
4689
|
+
// shapes `checkInitOnlyStarters`'s comment had already named and removed next
|
|
4690
|
+
// door, untouched.
|
|
4691
|
+
//
|
|
4692
|
+
// GATE 4 (`if (!edition) return`) — route (B). The project's own canonical
|
|
4693
|
+
// region is bytes this function already holds; an unknown edition says nothing
|
|
4694
|
+
// about whether those bytes are stale. The edition set is closed, so compare
|
|
4695
|
+
// against every track and report only when the region matches none.
|
|
4696
|
+
// GATE 5 (`catch { return }`) and GATE 6 (packaged region absent) — route (A),
|
|
4697
|
+
// and both must report. A packaged guide that cannot be read, or that carries
|
|
4698
|
+
// no canonical markers, is a damaged INSTALL. Reported with the same title as
|
|
4699
|
+
// the sibling package findings so one broken package reads as one problem.
|
|
4700
|
+
const edition = await inferProjectBundleEdition(cwd);
|
|
4701
|
+
const candidateEditions = edition ? [edition] : [...BUNDLE_EDITIONS];
|
|
4702
|
+
const usable = [];
|
|
4703
|
+
const damaged = [];
|
|
4704
|
+
for (const candidate of candidateEditions) {
|
|
4705
|
+
let packaged = null;
|
|
4706
|
+
let readError = null;
|
|
4707
|
+
try {
|
|
4708
|
+
// ⚠ `toLf` BEFORE CLASSIFYING, exactly as `checkInitOnlyStarters` does one
|
|
4709
|
+
// function away. Without it a CRLF checkout of the package (npm link with
|
|
4710
|
+
// `core.autocrlf=true`) had its markers masked away by the code-block
|
|
4711
|
+
// masker, and doctor reported an intact package as "carries no well-formed
|
|
4712
|
+
// guide-canonical markers" — a false statement about the packaged file.
|
|
4713
|
+
// It also keeps the byte comparison below honest: the project side is
|
|
4714
|
+
// normalized, so the packaged side has to be too, or every CRLF install
|
|
4715
|
+
// reports drift that is only a line ending.
|
|
4716
|
+
packaged = toLf(await readPackagedTemplate(candidate, 'scaffolding/AI-AGENT-GUIDE.md'));
|
|
4717
|
+
} catch (error) {
|
|
4718
|
+
readError = error;
|
|
4719
|
+
}
|
|
4720
|
+
const packagedRegion = packaged === null
|
|
4721
|
+
? null
|
|
4722
|
+
: classifyMarkedRegion(packaged, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
|
|
4723
|
+
if (packagedRegion && packagedRegion.state === 'present') {
|
|
4724
|
+
usable.push({ edition: candidate, packaged, region: packagedRegion });
|
|
4725
|
+
} else {
|
|
4726
|
+
const cause = packaged === null
|
|
4727
|
+
? `could not be read (${readError && readError.message ? readError.message : readError})`
|
|
4728
|
+
: 'carries no well-formed guide-canonical markers';
|
|
4729
|
+
damaged.push(`\`templates/${candidate}/scaffolding/AI-AGENT-GUIDE.md\` ${cause}`);
|
|
4730
|
+
}
|
|
4731
|
+
}
|
|
4732
|
+
if (damaged.length > 0) {
|
|
4733
|
+
const scope = edition
|
|
4734
|
+
? ''
|
|
4735
|
+
: ' This project does not say which track it uses, so every shipped track was checked.';
|
|
4736
|
+
findings.push({
|
|
4737
|
+
level: 'warn',
|
|
4738
|
+
title: 'The installed dflow package looks incomplete',
|
|
4739
|
+
detail: `Its packaged agent guide is unusable: ${damaged.join('; ')}.${scope} This report therefore cannot say whether ${AI_AGENT_GUIDE_DEST}'s canonical region is current.`,
|
|
4740
|
+
action: 'Reinstall dflow (e.g. `npm install -g dflow-sdd-ddd@latest`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project.'
|
|
4741
|
+
});
|
|
4742
|
+
}
|
|
4743
|
+
// ⚠⚠ ANY damaged candidate stops the COMPARISON, not just all of them. This
|
|
4744
|
+
// said `usable.length === 0` and that was a false-clean's mirror image — a
|
|
4745
|
+
// false DIRTY. With the edition unknown and one packaged guide unreadable, the
|
|
4746
|
+
// remaining track was compared alone and a pristine project guide matching the
|
|
4747
|
+
// UNREADABLE track was reported as drifted. The finding this function has
|
|
4748
|
+
// already pushed by now says, in its own words, that this report "cannot say
|
|
4749
|
+
// whether the canonical region is current" — and the next line said it anyway.
|
|
4750
|
+
//
|
|
4751
|
+
// The premise of the drift claim is "it matches NONE of the shipped tracks".
|
|
4752
|
+
// One unreadable candidate leaves that unestablished, so the honest output is
|
|
4753
|
+
// the package finding alone. Same evidence bar `checkInitOnlyStarters` reaches
|
|
4754
|
+
// by a different route: it compares only against `resolvedEdition`, because
|
|
4755
|
+
// naming a specific packaged file in a drift claim requires knowing which one.
|
|
4756
|
+
if (damaged.length > 0) return;
|
|
4757
|
+
const projectCanonical = content.slice(region.startIdx, region.endIdx);
|
|
4758
|
+
// ⚠ Matching ANY candidate clears it. With the track unknown the project could
|
|
4759
|
+
// legitimately be either one, so "differs from greenfield" alone is not drift.
|
|
4760
|
+
if (usable.some((candidate) => projectCanonical === candidate.packaged.slice(candidate.region.startIdx, candidate.region.endIdx))) return;
|
|
4761
|
+
findings.push({
|
|
4762
|
+
level: 'info',
|
|
4763
|
+
title: `${AI_AGENT_GUIDE_DEST} canonical content differs from this CLI version`,
|
|
4764
|
+
detail: usable.length === 1
|
|
4765
|
+
? 'The marker-guarded canonical region does not match what this Dflow version projects.'
|
|
4766
|
+
: 'The marker-guarded canonical region does not match what this Dflow version projects. This project does not say which track it uses, so every shipped track was checked and the region matches none of them.',
|
|
4767
|
+
action: 'Run `dflow configure-agents` to refresh the canonical region in place (content outside the markers is kept).'
|
|
4768
|
+
});
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4771
|
+
// PROPOSAL-076: the guide's "## Project Context" rows are the machine-readable
|
|
4772
|
+
// source context inference reads (tech stack / migration context). Unlike the
|
|
4773
|
+
// _overview.md rows the pre-076 inference looked for — which never existed in
|
|
4774
|
+
// any packaged template, so their absence is canonical — these rows have
|
|
4775
|
+
// shipped in every projected guide, so a missing or unparseable row here IS
|
|
4776
|
+
// drift worth reporting. Info-level only: the inference fallback
|
|
4777
|
+
// ('unknown'/'none') is benign, and rewriting Project Context is the user's
|
|
4778
|
+
// designed freedom (PROPOSAL-058 boundary).
|
|
4779
|
+
async function checkGuideProjectContextFormat(cwd, findings) {
|
|
4780
|
+
const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
|
|
4781
|
+
if (!(await pathExists(guidePath))) return; // missing guide is checkGuideCanonicalState's finding
|
|
4782
|
+
const content = toLf(await fs.readFile(guidePath, 'utf8').catch(() => ''));
|
|
4783
|
+
// Judge marker-managed guides and recognizable pre-marker guides. Anything
|
|
4784
|
+
// else already gets its own unrecognizable / malformed finding, where
|
|
4785
|
+
// row-level advice would be impossible advice. Deliberately NOT only the
|
|
4786
|
+
// adoption predicate: a marker-managed guide whose "## Project Context" was
|
|
4787
|
+
// deleted has fine markers and fails recognizability, yet inference just
|
|
4788
|
+
// lost its source — that is a finding, not a skip (gate G5).
|
|
4789
|
+
const markers = classifyMarkedRegion(content, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
|
|
4790
|
+
if (markers.state !== 'present' && !isRecognizableDflowGuide(content)) return;
|
|
4791
|
+
const section = projectContextParseSlice(content);
|
|
4792
|
+
if (section === null) {
|
|
4793
|
+
// ⚠ REPORT THE CAUSE WHEN THERE IS ONE (`p082-b3-k3` gap G). An unclosed
|
|
4794
|
+
// `<!--` above the heading puts it inside an HTML block, so `classifyLines`
|
|
4795
|
+
// does not see a heading and the section reads as absent — while it is
|
|
4796
|
+
// sitting right there in the file. `_conventions.md` already names this
|
|
4797
|
+
// cause and gives the line number; the guide gave the opaque message below
|
|
4798
|
+
// instead, whose action ("restore the section") is impossible advice for a
|
|
4799
|
+
// file that already has one, and whose "ignore this if you removed it on
|
|
4800
|
+
// purpose" invites dismissing a real malformation. Same cause, same
|
|
4801
|
+
// reporting.
|
|
4802
|
+
// ⚠⚠ WHY THE PREDICATE IS THE SYMPTOM AND NOT `unclosedHtmlBlockLine`,
|
|
4803
|
+
// measured on a real init'd guide before this was written. The obvious
|
|
4804
|
+
// version — reuse the `_conventions.md` unclosed-block reporting, which is
|
|
4805
|
+
// what the review recommended — almost never fires here: `## Project
|
|
4806
|
+
// Context` sits ABOVE the guide-canonical markers, and those marker lines
|
|
4807
|
+
// contain `-->`, so a `<!--` left open above the heading is CLOSED by the
|
|
4808
|
+
// marker below it. The block is well-formed at EOF, `unclosedHtmlBlockLine`
|
|
4809
|
+
// returns -1, and the heading is still inside that block, still invisible to
|
|
4810
|
+
// `classifyLines`, still reported by the opaque message below. A cause check
|
|
4811
|
+
// that cannot fire in the shipped file's own shape is worse than none: it
|
|
4812
|
+
// reports nothing while looking like coverage.
|
|
4813
|
+
// So ask the question that actually distinguishes the two states: is the
|
|
4814
|
+
// heading IN the file while the parser cannot see it? Fenced examples are
|
|
4815
|
+
// blanked first, so a heading inside a ```md block does not count as the
|
|
4816
|
+
// section — this branch deliberately names no block for it.
|
|
4817
|
+
// ⚠ It does NOT follow that such a file returned earlier. `debt212223-y3`
|
|
4818
|
+
// finding 2 measured the opposite: the early return above is a CONJUNCTION,
|
|
4819
|
+
// `markers.state !== 'present' && !isRecognizableDflowGuide(content)`, so any
|
|
4820
|
+
// guide written by a current `dflow init` — markers present — runs through here
|
|
4821
|
+
// however unrecognizable its body is, and lands on the generic finding below.
|
|
4822
|
+
// The decision was right; the reason written under it was not, and in a module
|
|
4823
|
+
// where these comments are the contract that is the expensive kind of wrong.
|
|
4824
|
+
const scan = doctorChecks.blankFencedBlocks(content.replace(/^\uFEFF/, ''));
|
|
4825
|
+
const classified = doctorChecks.classifyLines(scan);
|
|
4826
|
+
// ⚠⚠ ASK `parseAtxHeading`, DO NOT WRITE A FIFTH ATX RULE (`debt212223-y1`
|
|
4827
|
+
// finding 1). The first version tested `/^ {0,3}##[ \t]+Project Context[ \t]*#*[ \t]*$/`,
|
|
4828
|
+
// which accepts `## Project Context###` — CommonMark strips a closing sequence
|
|
4829
|
+
// only when a space precedes it, so `parseAtxHeading` reads that line's text as
|
|
4830
|
+
// `Project Context###` and the file does NOT have the heading. The finding
|
|
4831
|
+
// claimed it did, and the repair it suggested provably did not clear it.
|
|
4832
|
+
// `projectContextSectionBounds`, two functions above, carries a ⚠ recording that
|
|
4833
|
+
// it was moved OFF hand-rolled ATX rules for exactly this: it had become a
|
|
4834
|
+
// fourth place deciding what a heading is, a patch behind its siblings. This
|
|
4835
|
+
// was the fifth. There is now one rule and it lives in `doctor-checks`.
|
|
4836
|
+
// ⚠ BOUNDARY, stated rather than left to be discovered: this recognises the
|
|
4837
|
+
// **ATX** form only. A setext-underlined `Project Context` that is hidden by an
|
|
4838
|
+
// HTML block falls through to the generic finding below, whose action covers the
|
|
4839
|
+
// hidden case in words rather than by naming the block.
|
|
4840
|
+
const rawHeadingAt = scan.findIndex((line) => {
|
|
4841
|
+
const parsed = doctorChecks.parseAtxHeading(line);
|
|
4842
|
+
return parsed !== null && parsed.level === 2 && parsed.text === 'Project Context';
|
|
4843
|
+
});
|
|
4844
|
+
if (rawHeadingAt !== -1 && classified[rawHeadingAt] && classified[rawHeadingAt].type === 'html') {
|
|
4845
|
+
// ⚠⚠ THE OPENING LINE COMES FROM THE CLASSIFIER, NOT FROM A SCAN OF OUR OWN.
|
|
4846
|
+
// Three consecutive review rounds each defeated a reverse-scan version of this
|
|
4847
|
+
// (`debt212223-xv1` F2 → `xv2` F1 → `xv3` F1): walking back over contiguous
|
|
4848
|
+
// `html` lines crossed into the block above; requiring a line-start `<!--`
|
|
4849
|
+
// still named comment- or tag-shaped lines that sit INSIDE an open block
|
|
4850
|
+
// (`prose <!-- y`, `<p>x</p>`), because "can this line open a block" is not a
|
|
4851
|
+
// property of the line — it depends on cross-line state only `classifyLines`
|
|
4852
|
+
// holds. Three rounds on one predicate is this repo's signal to change the
|
|
4853
|
+
// shape rather than add a fourth patch, so `classifyLines` now carries
|
|
4854
|
+
// `blockStart` and this reads it. The fallback is only for a classifier that
|
|
4855
|
+
// did not supply it; it cannot be reached through `type === 'html'` today.
|
|
4856
|
+
const openerAt = typeof classified[rawHeadingAt].blockStart === 'number'
|
|
4857
|
+
? classified[rawHeadingAt].blockStart
|
|
4858
|
+
: rawHeadingAt;
|
|
4859
|
+
const openerLine = scan[openerAt].trim();
|
|
4860
|
+
const shownOpener = openerLine.length > 60 ? `${openerLine.slice(0, 60)}…` : openerLine;
|
|
4861
|
+
// ⚠⚠ ONE DECISION POINT, AND IT IS THE LINE-START RULE — not `includes('<!--')`
|
|
4862
|
+
// (`debt212223-xv4` finding 1). `<details><!-- note` opens a **type-6 tag**
|
|
4863
|
+
// block that merely contains a comment marker; advising `-->` there is advice
|
|
4864
|
+
// that does not work, and the reviewer confirmed by running it that the
|
|
4865
|
+
// heading stays inside the `<details>` afterwards and doctor repeats the same
|
|
4866
|
+
// finding. This is the SECOND site of this same rule in this change — the
|
|
4867
|
+
// opener scan was corrected for it one round earlier and this branch was left
|
|
4868
|
+
// behind, which is exactly the "fixed one member of the class" failure this
|
|
4869
|
+
// repo keeps paying for. Whoever touches either site greps for both.
|
|
4870
|
+
const openerIsComment = /^ {0,3}<!--/.test(scan[openerAt]);
|
|
4871
|
+
// ⚠⚠ IT REPORTS THE SHAPE, NOT A DIAGNOSIS, and the level matches that
|
|
4872
|
+
// (`debt212223-xv1` finding 1). The first version said "the section is
|
|
4873
|
+
// present in the file … do not add a second one" at `warn` — which is a
|
|
4874
|
+
// claim doctor cannot support: the same shape is produced by a comment left
|
|
4875
|
+
// open by accident (a live section is being swallowed) AND by a section the
|
|
4876
|
+
// adopter commented out on purpose (there is nothing to repair, and telling
|
|
4877
|
+
// them not to re-add it is telling them not to undo their own edit). It also
|
|
4878
|
+
// said "an HTML comment opens with `<!--` and runs until `-->`" on a block
|
|
4879
|
+
// that may be a `<details>`, sending `-->` advice about a tag that has none.
|
|
4880
|
+
// So: name the block that is actually there, quote its opening line, and let
|
|
4881
|
+
// the reader say which situation it is.
|
|
4882
|
+
findings.push({
|
|
4883
|
+
level: 'info',
|
|
4884
|
+
title: `${AI_AGENT_GUIDE_DEST} has a "## Project Context" heading at line ${rawHeadingAt + 1} that is inside an HTML block, so Dflow reads no Project Context section`,
|
|
4885
|
+
detail: `The block opens at line ${openerAt + 1} (\`${shownOpener}\`) and the heading sits inside it, so the heading is block content rather than a heading — \`dflow configure-agents\` falls back to \`unknown\` / \`none\` for tech stack and migration context. Two different situations produce exactly this shape and doctor cannot tell them apart: a block left open by accident is swallowing a live section, or the section was commented out deliberately.`,
|
|
4886
|
+
// ⚠⚠ THREE BRANCHES, BECAUSE "CLOSE IT" IS NOT ONE INSTRUCTION
|
|
4887
|
+
// (`debt212223-y4` finding 1). The previous version had two and told every
|
|
4888
|
+
// non-comment opener to "close it" — which is right for `<script>` / `<?` /
|
|
4889
|
+
// `<![CDATA[` and **provably useless for `<details>` and `<div>`**: a type-6
|
|
4890
|
+
// block ends at a BLANK LINE, so writing `</details>` above the heading
|
|
4891
|
+
// leaves the finding exactly as it was, with nothing telling the adopter
|
|
4892
|
+
// their repair was wrong. Measured both ways. And type 6 is precisely the
|
|
4893
|
+
// class this branch exists to serve, so it was the same "fixed one member of
|
|
4894
|
+
// the class" failure that `debt212223-xv4` had just closed one branch over.
|
|
4895
|
+
// ⚠ The deciding fact comes from `classifyLines` (`blockEndsOnBlank`), not
|
|
4896
|
+
// from re-inspecting the opener text here — that re-derivation is what cost
|
|
4897
|
+
// three earlier rounds.
|
|
4898
|
+
action: openerIsComment
|
|
4899
|
+
? `If the comment at line ${openerAt + 1} was left open mid-edit, close it with \`-->\` above the heading and re-run \`dflow doctor\`. If the section is commented out on purpose, nothing is broken — inference will keep falling back.`
|
|
4900
|
+
: classified[rawHeadingAt].blockEndsOnBlank === true
|
|
4901
|
+
? `A \`<details>\` / \`<div>\`-style block ends at a BLANK LINE, not at a closing tag — writing \`</...>\` above the heading will not free it. If the block at line ${openerAt + 1} is not meant to contain the heading, put a blank line between them, or move the section above the block; then re-run \`dflow doctor\`. If the heading belongs inside it, nothing is broken — inference will keep falling back.`
|
|
4902
|
+
: `Close the block opened at line ${openerAt + 1} with its own end marker (\`</script>\`, \`</style>\`, \`?>\`, \`]]>\`, \`>\`) above the heading, or move the section above the block; then re-run \`dflow doctor\`. If the heading belongs inside it, nothing is broken — inference will keep falling back.`
|
|
4903
|
+
});
|
|
4904
|
+
return;
|
|
4905
|
+
}
|
|
4906
|
+
findings.push({
|
|
4907
|
+
level: 'info',
|
|
4908
|
+
title: `${AI_AGENT_GUIDE_DEST} has no "## Project Context" section`,
|
|
4909
|
+
// ⚠ THE ACTION MUST NOT BE IMPOSSIBLE ADVICE (`debt212223-y1` finding 2). The
|
|
4910
|
+
// branch above names the HTML block for an ATX heading; a setext-underlined
|
|
4911
|
+
// `Project Context` hidden the same way reaches HERE, and telling that adopter
|
|
4912
|
+
// to "restore a section" they can see in their own file is the defect this
|
|
4913
|
+
// whole item exists to remove. So this message carries the possibility in
|
|
4914
|
+
// words: it costs a sentence and covers every heading syntax, including ones
|
|
4915
|
+
// added later.
|
|
4916
|
+
// ⚠ THE DETAIL STATES THE SHAPE; THE ACTION ENUMERATES (`debt212223-y4`
|
|
4917
|
+
// finding 2). It used to lead with "an HTML block above it — most often an
|
|
4918
|
+
// `<!--` left open" — a cause the branch above now takes for every ATX
|
|
4919
|
+
// heading, so the only file that can reach this line with that cause is one
|
|
4920
|
+
// with a setext-underlined heading. Naming it here pointed most readers at
|
|
4921
|
+
// the one thing that is not their problem.
|
|
4922
|
+
detail: '`dflow configure-agents` infers project context (tech stack, migration context) from that section\'s table rows; without it, inference falls back to `unknown` / `none`. Ignore this if you removed the section on purpose. ⚠ If the section IS in the file, then something is keeping that line from being read as a document-level heading — see the causes below.',
|
|
4923
|
+
// ⚠ NON-EXHAUSTIVE ON PURPOSE (`debt212223-xv7`). The first version said "two
|
|
4924
|
+
// things hide it" and named an HTML block or the heading line itself — but any
|
|
4925
|
+
// container the heading ends up inside does it too: an unclosed fence, a
|
|
4926
|
+
// blockquote, a list item, an indented code line, all reproduced. A closed
|
|
4927
|
+
// enumeration in adopter-facing advice is the same defect as a closed
|
|
4928
|
+
// enumeration in a rule: the reader who is in the (N+1)th case concludes the
|
|
4929
|
+
// advice does not apply to them. State the shape and give examples.
|
|
4930
|
+
action: 'If the section is not in the file, restore a "## Project Context" section above the guide-canonical markers, including the `| Tech stack | ... |` and `| Migration / legacy context | ... |` rows. If it IS there, then something is stopping that line from being a document-level `##` heading. Common causes: a container around it (an HTML block, an unclosed ``` fence, a blockquote, a list item, or a 4-space indent), or the heading line itself — a closing `###` with no space before it, a level other than `##`, or an invisible character in the text are all part of the heading and make it a different one.'
|
|
4931
|
+
});
|
|
4932
|
+
return;
|
|
4933
|
+
}
|
|
4934
|
+
const rows = [
|
|
4935
|
+
{ label: 'Tech stack', re: doctorChecks.TECH_STACK_ROW_RE, example: '| Tech stack | ASP.NET Core 9, EF Core, xUnit |', fallback: '`unknown`' },
|
|
4936
|
+
{ label: 'Migration / legacy context', re: doctorChecks.MIGRATION_CONTEXT_ROW_RE, example: '| Migration / legacy context | none |', fallback: '`none`' }
|
|
4937
|
+
];
|
|
4938
|
+
const missing = rows.filter((row) => doctorChecks.parseContextLine(section, row.re) === null);
|
|
4939
|
+
if (missing.length === 0) return;
|
|
4940
|
+
findings.push({
|
|
4941
|
+
level: 'info',
|
|
4942
|
+
title: `${AI_AGENT_GUIDE_DEST} "## Project Context" is missing machine-readable row(s): ${missing.map((row) => row.label).join(', ')}`,
|
|
4943
|
+
detail: `\`dflow configure-agents\` infers project context from these table rows; as written, inference falls back to ${missing.map((row) => row.fallback).join(' / ')}. Ignore this if you rewrote the section on purpose.`,
|
|
4944
|
+
action: `Restore the table row format inside "## Project Context", e.g. \`${missing[0].example}\`.`
|
|
4945
|
+
});
|
|
4946
|
+
}
|
|
4947
|
+
|
|
4948
|
+
// PROPOSAL-058 direction 2 (a): dangling `AI-AGENT-GUIDE.md § Heading`
|
|
4949
|
+
// references — the drift class that motivated this proposal: a frozen guide plus
|
|
4950
|
+
// a refreshed workflow bundle leaves flow files pointing at guide sections that
|
|
4951
|
+
// do not exist.
|
|
4952
|
+
async function checkGuideSectionRefs(cwd, findings) {
|
|
4953
|
+
const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
|
|
4954
|
+
if (!(await pathExists(guidePath))) return; // missing guide reported above
|
|
4955
|
+
const guideHeadings = doctorChecks.extractHeadings(await fs.readFile(guidePath, 'utf8').catch(() => ''));
|
|
4956
|
+
if (guideHeadings.length === 0) return;
|
|
4957
|
+
|
|
4958
|
+
const scanFiles = [{ rel: 'dflow/specs/shared/_conventions.md', abs: path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md') }];
|
|
4959
|
+
for (const dir of ['references', 'templates']) {
|
|
4960
|
+
const absoluteDir = path.join(cwd, WORKFLOW_BUNDLE_DEST, dir);
|
|
4961
|
+
let entries = [];
|
|
4962
|
+
try {
|
|
4963
|
+
entries = await fs.readdir(absoluteDir);
|
|
4964
|
+
} catch {
|
|
4965
|
+
continue;
|
|
4966
|
+
}
|
|
4967
|
+
for (const entry of entries) {
|
|
4968
|
+
if (entry.endsWith('.md')) {
|
|
4969
|
+
scanFiles.push({ rel: `${WORKFLOW_BUNDLE_DEST}/${dir}/${entry}`, abs: path.join(absoluteDir, entry) });
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
|
|
4974
|
+
const dangling = [];
|
|
4975
|
+
for (const file of scanFiles) {
|
|
4976
|
+
const content = await fs.readFile(file.abs, 'utf8').catch(() => null);
|
|
4977
|
+
if (content === null) continue;
|
|
4978
|
+
for (const ref of doctorChecks.extractSectionRefs(content, 'AI-AGENT-GUIDE.md')) {
|
|
4979
|
+
if (!doctorChecks.headingResolves(ref.headingText, guideHeadings)) {
|
|
4980
|
+
dangling.push(`${file.rel}:${ref.line} § "${ref.headingText}"`);
|
|
4981
|
+
}
|
|
4982
|
+
}
|
|
4983
|
+
}
|
|
4984
|
+
if (dangling.length === 0) return;
|
|
4985
|
+
const shown = dangling.slice(0, 8);
|
|
4986
|
+
const more = dangling.length - shown.length;
|
|
4987
|
+
findings.push({
|
|
4988
|
+
level: 'warn',
|
|
4989
|
+
title: `Dangling AI-AGENT-GUIDE.md § reference(s): ${dangling.length}`,
|
|
4990
|
+
detail: `${shown.join('; ')}${more > 0 ? `; +${more} more` : ''} — the guide has no matching heading, usually because it is frozen at an older Dflow version than the workflow bundle.`,
|
|
4991
|
+
action: 'Refresh the guide canonical content with `dflow configure-agents` (accept marker adoption if offered), or align the guide manually.'
|
|
4992
|
+
});
|
|
4993
|
+
}
|
|
4994
|
+
|
|
4995
|
+
// PROPOSAL-058 direction 2 (d), user decision 2026-06-08 OQ5: init-only starters
|
|
4996
|
+
// are user-owned and never re-projected — doctor only reports, never rewrites.
|
|
4997
|
+
async function checkInitOnlyStarters(cwd, findings) {
|
|
4998
|
+
const conventions = await fs.readFile(path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md'), 'utf8').catch(() => '');
|
|
4999
|
+
const parsedPolicy = doctorChecks.parseContextLine(conventions, doctorChecks.GIT_POLICY_LINE_RE);
|
|
5000
|
+
const recordedPolicy = parsedPolicy && doctorChecks.GIT_POLICY_VALUES.has(parsedPolicy) ? parsedPolicy : null;
|
|
5001
|
+
const edition = await inferProjectBundleEdition(cwd);
|
|
5002
|
+
|
|
5003
|
+
// ⚠⚠ PROPOSAL-091 gate 1 — route (B), with (A) as the fallback. `if (policy)`
|
|
5004
|
+
// with no else was the seventh boundary drawn around a package check in this
|
|
5005
|
+
// file, and it was the widest: a project with no `## Git Policy` section lost
|
|
5006
|
+
// the WHOLE Git-principles block — presence and drift both — while the only
|
|
5007
|
+
// thing doctor said was a separate warn about the missing section that never
|
|
5008
|
+
// mentioned what it had switched off. That is the same lesson as the comment
|
|
5009
|
+
// below: the policy value decides WHICH starter to compare against, and that
|
|
5010
|
+
// is a fact about the project's shape. Which `Git-principles-*.md` files the
|
|
5011
|
+
// project actually HAS is visible on disk, so when the value is missing we
|
|
5012
|
+
// check every one of them instead of checking none.
|
|
5013
|
+
//
|
|
5014
|
+
// ⚠ The absent value really does stop one thing, and only one: with no policy
|
|
5015
|
+
// recorded doctor cannot say a needed file is MISSING, because it does not
|
|
5016
|
+
// know which one was needed. The loop below only ever visits policies whose
|
|
5017
|
+
// file is present, so that branch is unreachable on this path by construction
|
|
5018
|
+
// rather than by wording.
|
|
5019
|
+
const presentPolicies = [];
|
|
5020
|
+
for (const candidate of doctorChecks.GIT_POLICY_VALUES) {
|
|
5021
|
+
if (await pathExists(path.join(cwd, 'dflow', 'specs', 'shared', `Git-principles-${candidate}.md`))) {
|
|
5022
|
+
presentPolicies.push(candidate);
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
5025
|
+
const policiesToCheck = recordedPolicy ? [recordedPolicy] : presentPolicies;
|
|
5026
|
+
|
|
5027
|
+
for (const policy of policiesToCheck) {
|
|
5028
|
+
const relativePath = `dflow/specs/shared/Git-principles-${policy}.md`;
|
|
5029
|
+
const absolute = path.join(cwd, relativePath);
|
|
5030
|
+
const projectFileExists = await pathExists(absolute);
|
|
5031
|
+
// ⚠⚠ WITHOUT A RECORDED POLICY, `configure-agents` REFUSES TO TOUCH ANY OF
|
|
5032
|
+
// THESE FILES — `addGitPrinciplesItem` reports it and returns before writing
|
|
5033
|
+
// a byte. So every action below that sends the reader there has to say what
|
|
5034
|
+
// to do first. Promising a refresh the sibling command will decline is the
|
|
5035
|
+
// false-claim-about-another-command class this check has already corrected
|
|
5036
|
+
// twice (`p090-b3-z1`), and route (B) would otherwise re-introduce it at the
|
|
5037
|
+
// exact boundary this proposal exists to be honest about.
|
|
5038
|
+
// ⚠ Name THIS file's policy, not a fixed example. The loop is here because
|
|
5039
|
+
// `Git-principles-<policy>.md` is on disk, so the value to restore is known
|
|
5040
|
+
// — and a hardcoded `gitflow` in a finding about a trunk project reads as an
|
|
5041
|
+
// instruction, which would have the reader write the wrong policy into their
|
|
5042
|
+
// conventions. The sibling strings nearby hedge with "e.g." or "or `trunk`"
|
|
5043
|
+
// precisely because they cannot know; this one can.
|
|
5044
|
+
const restorePolicyFirst = recordedPolicy
|
|
5045
|
+
? ''
|
|
5046
|
+
: `Restore the \`## Git Policy\` section of \`dflow/specs/shared/_conventions.md\` first — this file is the \`${policy}\` starter, so the canonical line is \`Selected Git policy: \`${policy}\`\`. While the section is missing, \`dflow configure-agents\` declines to refresh any Git principles file. Then: `;
|
|
5047
|
+
|
|
5048
|
+
// ⚠⚠ WHICH TRACKS COULD `configure-agents` READ? THE EDITION SET IS CLOSED,
|
|
5049
|
+
// SO WHEN THE PROJECT CANNOT TELL US, CHECK THEM ALL. This is the fifth
|
|
5050
|
+
// boundary guessed around a package check in this file and the fourth one
|
|
5051
|
+
// that shipped as a false clean; the previous four are recorded on
|
|
5052
|
+
// `checkWorkflowBundleSourceAndOrphans`, which reached exactly this shape
|
|
5053
|
+
// and wrote down why. Each earlier guess here was narrower than the last —
|
|
5054
|
+
// "the project file exists" (`p090-b3-x1r`), then "an edition is inferable"
|
|
5055
|
+
// (`p090-b3-x2r`) — and each was reported as the fix.
|
|
5056
|
+
// `p090-b3-x2r` executed it: strip the manifest and every structural signal
|
|
5057
|
+
// from a `trunk` project, break BOTH packaged trunk starters, and doctor
|
|
5058
|
+
// printed `All checks passed` while `configure-agents`, having asked the
|
|
5059
|
+
// user which track to use, died on
|
|
5060
|
+
// `Internal error: packaged Git-principles-trunk.md has no well-formed
|
|
5061
|
+
// git-principles-canonical markers.`
|
|
5062
|
+
//
|
|
5063
|
+
// ⚠ The two resolvers do not agree in general — this check prefers the
|
|
5064
|
+
// manifest, `configure-agents` resolves by structure — so a single track is
|
|
5065
|
+
// assumed ONLY when both name the same one (`projgate-sol1`). Otherwise
|
|
5066
|
+
// every track the selected policy could resolve to is a candidate.
|
|
5067
|
+
const structureEdition = await inferExistingEdition(cwd);
|
|
5068
|
+
const resolvedEdition = edition && edition === structureEdition ? edition : null;
|
|
5069
|
+
const candidateEditions = resolvedEdition ? [resolvedEdition] : [...BUNDLE_EDITIONS];
|
|
5070
|
+
|
|
5071
|
+
const packagedByEdition = new Map();
|
|
5072
|
+
for (const candidate of candidateEditions) {
|
|
5073
|
+
let candidateTemplate = null;
|
|
5074
|
+
let candidateError = null;
|
|
5075
|
+
try {
|
|
5076
|
+
candidateTemplate = await readPackagedTemplate(candidate, `scaffolding/Git-principles-${policy}.md`);
|
|
5077
|
+
} catch (error) {
|
|
5078
|
+
candidateError = error;
|
|
5079
|
+
}
|
|
5080
|
+
// ⚠ An UNREADABLE packaged starter belongs here too, not one branch
|
|
5081
|
+
// earlier: the old `catch { template = null; }` plus `if (template)` made
|
|
5082
|
+
// a missing packaged file the quietest state of all. Same damage, same
|
|
5083
|
+
// remedy, so it is one finding with the cause named.
|
|
5084
|
+
const candidateRegion = candidateTemplate === null
|
|
5085
|
+
? null
|
|
5086
|
+
: classifyMarkedRegion(
|
|
5087
|
+
toLf(candidateTemplate),
|
|
5088
|
+
GIT_PRINCIPLES_CANONICAL_START,
|
|
5089
|
+
GIT_PRINCIPLES_CANONICAL_END
|
|
5090
|
+
);
|
|
5091
|
+
packagedByEdition.set(candidate, {
|
|
5092
|
+
template: candidateTemplate,
|
|
5093
|
+
error: candidateError,
|
|
5094
|
+
region: candidateRegion,
|
|
5095
|
+
usable: candidateRegion !== null && candidateRegion.state === 'present'
|
|
5096
|
+
});
|
|
5097
|
+
}
|
|
5098
|
+
|
|
5099
|
+
const damagedEditions = candidateEditions.filter((candidate) => !packagedByEdition.get(candidate).usable);
|
|
5100
|
+
if (damagedEditions.length > 0) {
|
|
5101
|
+
// ⚠ Say the decisive thing only when it is true. `configure-agents` is
|
|
5102
|
+
// certain to fail when EVERY track it could resolve to is damaged — which
|
|
5103
|
+
// covers both "we know the track and it is broken" and "we do not know the
|
|
5104
|
+
// track but all of them are broken". With only some damaged, the reader is
|
|
5105
|
+
// told what is actually known instead of what sounds decisive.
|
|
5106
|
+
const certain = damagedEditions.length === candidateEditions.length;
|
|
5107
|
+
const causes = damagedEditions.map((candidate) => {
|
|
5108
|
+
const entry = packagedByEdition.get(candidate);
|
|
5109
|
+
const cause = entry.region === null
|
|
5110
|
+
? `could not be read (${entry.error && entry.error.message ? entry.error.message : entry.error})`
|
|
5111
|
+
: 'carries no well-formed git-principles-canonical markers';
|
|
5112
|
+
return `\`templates/${candidate}/scaffolding/Git-principles-${policy}.md\` ${cause}`;
|
|
5113
|
+
});
|
|
5114
|
+
const scope = resolvedEdition
|
|
5115
|
+
? ''
|
|
5116
|
+
: ' This project does not say which track it uses, so every track the selected policy could resolve to was checked.';
|
|
5117
|
+
// ⚠ THE THIRD SPELLING OF `alsoFails`, AND ROUTE (B) IS WHY. Both of the
|
|
5118
|
+
// originals assume `configure-agents` will get as far as reading this
|
|
5119
|
+
// starter — true only while a policy IS recorded. With none recorded it
|
|
5120
|
+
// never gets there: `addGitPrinciplesItem` reports the missing section and
|
|
5121
|
+
// returns before touching the package, so "fails on this package" would be
|
|
5122
|
+
// a confident false statement about the sibling command on the one path
|
|
5123
|
+
// this gate newly reaches.
|
|
5124
|
+
const alsoFails = !recordedPolicy
|
|
5125
|
+
? '`dflow configure-agents` does not reach it either way: with no Git policy recorded it declines to refresh any Git principles file.'
|
|
5126
|
+
: (certain
|
|
5127
|
+
? '`dflow configure-agents` fails on this package before writing a byte.'
|
|
5128
|
+
: 'Whether `dflow configure-agents` also fails depends on which track it resolves this project to.');
|
|
5129
|
+
findings.push({
|
|
5130
|
+
level: 'warn',
|
|
5131
|
+
title: 'The installed dflow package looks incomplete',
|
|
5132
|
+
detail: `Its packaged Git principles starter is unusable: ${causes.join('; ')}.${scope} This report therefore cannot say whether ${relativePath}'s canonical sections are current.`,
|
|
5133
|
+
action: `Reinstall dflow (e.g. \`npm install -g dflow-sdd-ddd@latest\`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project — ${alsoFails}`
|
|
5134
|
+
});
|
|
5135
|
+
}
|
|
5136
|
+
|
|
5137
|
+
// ⚠⚠ THE COMPARISON IS ROUTE (B) NOW, NOT `resolvedEdition` ONLY — and the
|
|
5138
|
+
// history of this line is worth keeping, because BOTH of its earlier forms
|
|
5139
|
+
// were wrong in opposite directions.
|
|
5140
|
+
//
|
|
5141
|
+
// It once compared against whichever single track the manifest named.
|
|
5142
|
+
// `p090-b3-y1` executed that gap: manifest forced to brownfield on a
|
|
5143
|
+
// structurally greenfield project, doctor compared the greenfield file
|
|
5144
|
+
// against the BROWNFIELD starter and reported drift that did not exist — a
|
|
5145
|
+
// resolver disagreement wearing a content finding. The repair narrowed it to
|
|
5146
|
+
// `resolvedEdition` (manifest and structure agreeing), which removed the
|
|
5147
|
+
// false DIRTY and installed a false CLEAN in its place: with the edition
|
|
5148
|
+
// uninferable the whole comparison vanished and said nothing. Measured on
|
|
5149
|
+
// the same mutation — an edited canonical region reported when the edition
|
|
5150
|
+
// was known, `All checks passed` when it was not.
|
|
5151
|
+
//
|
|
5152
|
+
// ⚠ And this proposal's own gate-1 work routed NEW traffic into that hole:
|
|
5153
|
+
// route (B) now enters this loop when no policy is recorded, while the (A)
|
|
5154
|
+
// fallback is suppressed exactly then (the starter file IS on disk, so
|
|
5155
|
+
// `policiesToCheck` is non-empty). Policy unrecorded + edition uninferable +
|
|
5156
|
+
// a drifted file produced neither the drift nor the "which checks did not
|
|
5157
|
+
// run" disclosure.
|
|
5158
|
+
//
|
|
5159
|
+
// Comparing every candidate and clearing on ANY match answers both: the
|
|
5160
|
+
// greenfield project matches the greenfield starter whatever the manifest
|
|
5161
|
+
// claims, so `p090-b3-y1`'s false dirty stays fixed, and an unknown edition
|
|
5162
|
+
// no longer decides whether we look. ⚠ The finding's own wording never named
|
|
5163
|
+
// a packaged file — "differs from the current packaged starter", with the
|
|
5164
|
+
// PROJECT's path in the title — so the old comment's justification for
|
|
5165
|
+
// narrowing ("that sentence names a specific packaged file") was defending
|
|
5166
|
+
// a sentence this code does not print.
|
|
5167
|
+
//
|
|
5168
|
+
// ⚠ Two editions really do ship different canonical regions for the same
|
|
5169
|
+
// policy (measured: trunk 10602 vs 10601 bytes, gitflow 11414 vs 11089), so
|
|
5170
|
+
// this is observable, not latent.
|
|
5171
|
+
const comparableEditions = damagedEditions.length === 0 ? candidateEditions : [];
|
|
5172
|
+
|
|
5173
|
+
if (!projectFileExists) {
|
|
5174
|
+
// ⚠ The recovery action may not send the reader to a scratch `dflow init`
|
|
5175
|
+
// while we KNOW that init would read the damaged starter — it would hand
|
|
5176
|
+
// them the same broken file and look like their own mistake. Same
|
|
5177
|
+
// discipline as `alsoFails`: say the confident thing only when it is true.
|
|
5178
|
+
findings.push({
|
|
5179
|
+
level: 'warn',
|
|
5180
|
+
title: `${relativePath} is missing`,
|
|
5181
|
+
detail: `The selected Git policy (\`${policy}\`) names this principles file; runtime branch gates and finish-feature guidance read it.`,
|
|
5182
|
+
// ⚠ The condition is "is every starter a scratch init could read
|
|
5183
|
+
// damaged", not "do we know the track". `p090-b3-y1` caught the
|
|
5184
|
+
// earlier `edition && !packagedUsable` form still sending the reader
|
|
5185
|
+
// to a scratch init when the edition was unknown and BOTH candidate
|
|
5186
|
+
// starters were broken — the one case where that advice is most
|
|
5187
|
+
// certainly wrong, because whichever track init picks it is damaged.
|
|
5188
|
+
action: damagedEditions.length > 0 && damagedEditions.length === candidateEditions.length
|
|
5189
|
+
? 'Reinstall dflow first — the packaged copy of this starter is itself unusable (reported above), so a scratch `dflow init` would reproduce the damage. Then recover the file from a fresh `dflow init` in a scratch directory.'
|
|
5190
|
+
: 'Recover it from a fresh `dflow init` in a scratch directory.'
|
|
5191
|
+
});
|
|
5192
|
+
} else {
|
|
5193
|
+
// ⚠⚠ THE PROJECT-SIDE CHECKS DO NOT HANG OFF `edition`, AND TWO OF THE
|
|
5194
|
+
// THREE NEVER NEEDED IT. "Your markers are malformed" and "your file
|
|
5195
|
+
// predates the markers" are properties of the PROJECT'S OWN FILE — they
|
|
5196
|
+
// are decided by `classifyMarkedRegion` on bytes this function already
|
|
5197
|
+
// has, and no packaged template is consulted to reach either verdict.
|
|
5198
|
+
// Only the third branch, the canonical COMPARISON, needs the packaged
|
|
5199
|
+
// side, and it carries its own guard.
|
|
5200
|
+
//
|
|
5201
|
+
// ⚠ Gating all three on `edition` was the sixth boundary drawn around
|
|
5202
|
+
// this check and the last one still standing. `p090-b3-y2` executed it:
|
|
5203
|
+
// strip the manifest and every structural signal but keep
|
|
5204
|
+
// `_conventions.md` with its selected policy, and a pre-marker,
|
|
5205
|
+
// malformed, or stale project file produced
|
|
5206
|
+
// `All checks passed. No Dflow health findings detected.` — while
|
|
5207
|
+
// `configure-agents`, which resolves the track by asking, went on to warn
|
|
5208
|
+
// or refresh. An unknown edition is a fact about the PROJECT'S SHAPE; it
|
|
5209
|
+
// says nothing about whether the adopter's own file is broken, so it must
|
|
5210
|
+
// not decide whether we look.
|
|
5211
|
+
let projected = '';
|
|
5212
|
+
let unreadable = false;
|
|
5213
|
+
try {
|
|
5214
|
+
projected = await fs.readFile(absolute, 'utf8');
|
|
5215
|
+
} catch {
|
|
5216
|
+
unreadable = true;
|
|
5217
|
+
}
|
|
5218
|
+
// PROPOSAL-090 route B3: sections 1-5 are Dflow-canonical and
|
|
5219
|
+
// `configure-agents` refreshes them in place; everything else in this
|
|
5220
|
+
// file is the project's. Comparing the WHOLE file reported drift for
|
|
5221
|
+
// every project that had ever filled in its own CI / CD section, which
|
|
5222
|
+
// is the normal state — so the signal was noise and stayed unread.
|
|
5223
|
+
// Four states, reported apart on purpose.
|
|
5224
|
+
const region = classifyMarkedRegion(
|
|
5225
|
+
toLf(projected),
|
|
5226
|
+
GIT_PRINCIPLES_CANONICAL_START,
|
|
5227
|
+
GIT_PRINCIPLES_CANONICAL_END
|
|
5228
|
+
);
|
|
5229
|
+
if (region.state === 'malformed') {
|
|
5230
|
+
// ⚠ Never fold this into `absent`. A file whose markers were broken
|
|
5231
|
+
// by an edit would then read as "predates the markers", and the
|
|
5232
|
+
// adoption offer would replace sections 1-5 of a file nobody has
|
|
5233
|
+
// looked at. Say what is actually wrong.
|
|
5234
|
+
findings.push({
|
|
5235
|
+
level: 'warn',
|
|
5236
|
+
title: `${relativePath} has malformed git-principles-canonical markers`,
|
|
5237
|
+
detail: 'The START / END pair is incomplete or out of order, so `configure-agents` cannot refresh the canonical sections and will leave the file untouched.',
|
|
5238
|
+
action: `${restorePolicyFirst}Repair or remove the stray \`<!-- dflow-generated: git-principles-canonical ... -->\` markers, then re-run \`dflow configure-agents\`.`
|
|
5239
|
+
});
|
|
5240
|
+
} else if (region.state === 'absent') {
|
|
5241
|
+
// ⚠⚠ ONLY PROMISE THE OFFER WHEN THE OFFER WILL ACTUALLY BE MADE.
|
|
5242
|
+
// `configure-agents` offers marker adoption only for a file it can still
|
|
5243
|
+
// recognise — both heading anchors present exactly once
|
|
5244
|
+
// (`gitPrinciplesCanonicalBounds`). `p090-b3-z1` executed the gap: rename
|
|
5245
|
+
// `## 1. Branch Structure` and doctor still said "accept the
|
|
5246
|
+
// marker-adoption offer" while `configure-agents` never asked. That is a
|
|
5247
|
+
// false claim about another command — the class this file has already
|
|
5248
|
+
// corrected twice — and `docs/upgrading.*` documents the distinction the
|
|
5249
|
+
// code was not making.
|
|
5250
|
+
// ⚠ `unreadable` is split out for the same reason: a file we could not
|
|
5251
|
+
// read produced an empty string, which classifies as `absent`, and doctor
|
|
5252
|
+
// then reported "predates the markers" about content it never saw.
|
|
5253
|
+
const recognizable = gitPrinciplesCanonicalBounds(toLf(projected)) !== null;
|
|
5254
|
+
if (unreadable) {
|
|
5255
|
+
findings.push({
|
|
5256
|
+
level: 'warn',
|
|
5257
|
+
title: `${relativePath} could not be read`,
|
|
5258
|
+
detail: 'It exists but this check could not read it, so nothing can be said about whether its canonical sections are current.',
|
|
5259
|
+
action: 'Check the file\'s permissions and encoding, then re-run `dflow doctor`.'
|
|
5260
|
+
});
|
|
5261
|
+
} else if (recognizable) {
|
|
5262
|
+
findings.push({
|
|
5263
|
+
level: 'info',
|
|
5264
|
+
title: `${relativePath} predates managed git-principles-canonical markers`,
|
|
5265
|
+
detail: 'Its canonical sections (1-5) stay at the Dflow version that seeded them, while the workflow flows that read them are re-projected on every upgrade.',
|
|
5266
|
+
action: `${restorePolicyFirst}Re-run \`dflow configure-agents\` on an interactive terminal and accept the marker-adoption offer; it keeps your file header and everything from "## 6. AI Collaboration Rules (Project Policy)" down.`
|
|
5267
|
+
});
|
|
5268
|
+
} else {
|
|
5269
|
+
findings.push({
|
|
5270
|
+
level: 'info',
|
|
5271
|
+
title: `${relativePath} is not recognizable as a Dflow Git principles starter`,
|
|
5272
|
+
detail: `It has no git-principles-canonical markers, and its \`${GIT_PRINCIPLES_CANONICAL_FIRST_HEADING}\` / \`${GIT_PRINCIPLES_CANONICAL_AFTER_HEADING}\` headings were not both found exactly once, so Dflow cannot tell where its own sections end and yours begin.`,
|
|
5273
|
+
action: 'No marker-adoption offer is made for this file. Restore both headings if it should be Dflow-managed, or keep maintaining it yourself.'
|
|
5274
|
+
});
|
|
5275
|
+
}
|
|
5276
|
+
} else if (comparableEditions.length > 0) {
|
|
5277
|
+
const projectedCanonical = toLf(projected).slice(region.startIdx, region.endIdx);
|
|
5278
|
+
// Clearing on ANY candidate is the whole point: the project's file only
|
|
5279
|
+
// has to be a current starter for SOME shipped track to be current.
|
|
5280
|
+
const matchesSome = comparableEditions.some((candidate) => {
|
|
5281
|
+
const entry = packagedByEdition.get(candidate);
|
|
5282
|
+
return toLf(entry.template).slice(entry.region.startIdx, entry.region.endIdx) === projectedCanonical;
|
|
5283
|
+
});
|
|
5284
|
+
if (!matchesSome) {
|
|
5285
|
+
findings.push({
|
|
5286
|
+
level: 'info',
|
|
5287
|
+
title: `${relativePath} canonical sections differ from the current packaged starter`,
|
|
5288
|
+
detail: comparableEditions.length === 1
|
|
5289
|
+
? 'Only sections 1-5 are compared; your own sections are not. This is either your edit inside the managed region, or a starter that has not been refreshed yet.'
|
|
5290
|
+
: 'Only sections 1-5 are compared; your own sections are not. This project does not say which track it uses, so every shipped track was checked and the sections match none of them. This is either your edit inside the managed region, or a starter that has not been refreshed yet.',
|
|
5291
|
+
action: `${restorePolicyFirst}Run \`dflow configure-agents\` to refresh the canonical sections in place; content outside the markers is kept.`
|
|
5292
|
+
});
|
|
5293
|
+
}
|
|
5294
|
+
}
|
|
5295
|
+
}
|
|
5296
|
+
}
|
|
5297
|
+
|
|
5298
|
+
// ⚠ (A), and only as the fallback route (B) cannot cover. With no policy
|
|
5299
|
+
// recorded AND no `Git-principles-*.md` on disk there is genuinely nothing to
|
|
5300
|
+
// compare, so this is the one shape where the missing value really does stop
|
|
5301
|
+
// the check — and doctor reports by exception, so staying silent here would
|
|
5302
|
+
// read as "those checks ran and found nothing".
|
|
5303
|
+
if (!recordedPolicy && policiesToCheck.length === 0) {
|
|
5304
|
+
// ⚠ "Add the section to the file" is the wrong instruction when the file is
|
|
5305
|
+
// not there — and this branch is reached most easily in exactly that case,
|
|
5306
|
+
// where the sibling finding below already tells the reader to recover the
|
|
5307
|
+
// whole file. Two findings in one report that disagree about what the reader
|
|
5308
|
+
// should do next is the confusion this proposal exists to remove, so ask
|
|
5309
|
+
// which state it is rather than wording for the commoner one.
|
|
5310
|
+
const conventionsMissing = !(await pathExists(path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md')));
|
|
5311
|
+
findings.push({
|
|
5312
|
+
level: 'info',
|
|
5313
|
+
title: 'The Git-principles starter checks did not run',
|
|
5314
|
+
detail: `${conventionsMissing
|
|
5315
|
+
? 'There is no `dflow/specs/shared/_conventions.md` to read a Git policy from'
|
|
5316
|
+
: 'No Git policy is recorded in `dflow/specs/shared/_conventions.md`'}, and no \`dflow/specs/shared/Git-principles-*.md\` file was found to check on its own. Doctor cannot tell which principles file this project needs, so it checked neither its presence nor whether its canonical sections have drifted from the current packaged starter — and the runtime branch gates and finish-feature guidance read that file.`,
|
|
5317
|
+
action: conventionsMissing
|
|
5318
|
+
? 'Recover `_conventions.md` first (see the finding about it below), keeping its `## Git Policy` section, then re-run `dflow configure-agents` and `dflow doctor`.'
|
|
5319
|
+
: 'Add the `## Git Policy` section to `_conventions.md` (canonical line: `Selected Git policy: `gitflow``, or `trunk`), then re-run `dflow configure-agents` and `dflow doctor`. While the policy is unrecorded, `configure-agents` also declines to refresh Git principles.'
|
|
5320
|
+
});
|
|
5321
|
+
}
|
|
5322
|
+
|
|
5323
|
+
// PROPOSAL-082 G5 / PROPOSAL-083 §4 — report a `_conventions.md` whose
|
|
5324
|
+
// sections predate the current contracts. User-owned: report only, never edit.
|
|
5325
|
+
//
|
|
5326
|
+
// `missing` and `stale` are reported at different levels on purpose.
|
|
5327
|
+
// `### SPEC-ID Format` and `### Slug Conventions` were re-parented by `59e0eb2`
|
|
5328
|
+
// on 2026-05-01 and no released version has ever projected them, so EVERY
|
|
5329
|
+
// existing project is `missing` for that one — it is content the developer was
|
|
5330
|
+
// never offered, not a health problem, and `info` says so without crying wolf.
|
|
5331
|
+
// `stale` means the section IS there and states a rule the shipped flows no
|
|
5332
|
+
// longer follow, which is a live contradiction inside their own conventions,
|
|
5333
|
+
// so it warns.
|
|
5334
|
+
// The file being GONE is the worst state of all, and it was the one state
|
|
5335
|
+
// doctor said nothing about: every `_conventions` check early-returns on a
|
|
5336
|
+
// missing path, and findConventionsDrift returns [] for empty input. Silence
|
|
5337
|
+
// on the worst case is the "reports success" failure this check exists to
|
|
5338
|
+
// avoid, so it is reported first and the drift scan is skipped (three
|
|
5339
|
+
// fingerprint findings about a file that does not exist is noise).
|
|
5340
|
+
// Present-but-empty is handled with absent, not with drift: a whitespace-only
|
|
5341
|
+
// file would otherwise emit one finding per fingerprint, which is noise about
|
|
5342
|
+
// a file that has no content at all. It must not fall through silently
|
|
5343
|
+
// either — that was the gap, and it made an emptier file look healthier than
|
|
5344
|
+
// a one-character one.
|
|
5345
|
+
const conventionsPath = path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md');
|
|
5346
|
+
const conventionsAbsent = !(await pathExists(conventionsPath));
|
|
5347
|
+
// ⚠ THREE STATES, NOT TWO. The read at the top of this function collapses to
|
|
5348
|
+
// '' on any error, so a file that is present with its contents intact — locked
|
|
5349
|
+
// by permissions, or replaced by a directory — was reported as "is empty",
|
|
5350
|
+
// and the action told the reader to recover it from a scratch init and retype
|
|
5351
|
+
// their answers. That is destructive advice about a file whose content is
|
|
5352
|
+
// still there. The `conventionsUnreadable` branch keeps the coupling this
|
|
5353
|
+
// block is load-bearing for (three sibling checks stay silent about an absent
|
|
5354
|
+
// or blank file ONLY because this one always reports it) — it still always
|
|
5355
|
+
// reports, it just stops guessing which of the three it is.
|
|
5356
|
+
const conventionsUnreadable = !conventionsAbsent && conventions === '' && await pathExists(conventionsPath)
|
|
5357
|
+
? !(await fs.readFile(conventionsPath, 'utf8').then(() => true).catch(() => false))
|
|
5358
|
+
: false;
|
|
5359
|
+
if (conventionsAbsent || conventionsUnreadable || !conventions.trim()) {
|
|
5360
|
+
findings.push({
|
|
5361
|
+
level: 'warn',
|
|
5362
|
+
title: conventionsAbsent
|
|
5363
|
+
? 'dflow/specs/shared/_conventions.md is missing'
|
|
5364
|
+
: (conventionsUnreadable
|
|
5365
|
+
? 'dflow/specs/shared/_conventions.md could not be read'
|
|
5366
|
+
: 'dflow/specs/shared/_conventions.md is empty'),
|
|
5367
|
+
detail: conventionsUnreadable
|
|
5368
|
+
? 'It exists but this check could not read it, so none of the conventions, Git policy or AI commit marker it records could be checked. Its contents may be perfectly intact.'
|
|
5369
|
+
: 'It records this project\'s spec-writing conventions, Git policy and AI commit marker, and Dflow reads it to answer those questions. Without it, doctor cannot check any of them.',
|
|
5370
|
+
action: conventionsUnreadable
|
|
5371
|
+
? 'Check that it is a file and that its permissions allow reading, then re-run `dflow doctor`. Do not recreate it until you have confirmed the content is actually lost.'
|
|
5372
|
+
: 'Recover it from a fresh `dflow init` in a scratch directory and re-apply your project-specific answers; it is user-owned, so `dflow configure-agents` does not re-project it.'
|
|
5373
|
+
});
|
|
5374
|
+
} else {
|
|
5375
|
+
const conventionsDrift = doctorChecks.findConventionsDrift(conventions, edition);
|
|
5376
|
+
for (const drift of conventionsDrift) {
|
|
5377
|
+
const missing = drift.state === 'missing';
|
|
5378
|
+
// Say only what is known. A section can be absent because the project
|
|
5379
|
+
// predates it OR because the heading was renamed, and doctor cannot tell
|
|
5380
|
+
// which — an earlier version asserted the first, which is a false
|
|
5381
|
+
// statement to show someone who merely retitled a heading.
|
|
5382
|
+
const neverShipped = missing && drift.neverProjected
|
|
5383
|
+
? ' No released version before this one projected this section, so a project created earlier will not have it.'
|
|
5384
|
+
: '';
|
|
5385
|
+
const detail = {
|
|
5386
|
+
missing: `Nothing under a heading matching "${drift.heading}" was found, so ${drift.rule} is not recorded. Either the section is absent or its heading was renamed.${neverShipped}`,
|
|
5387
|
+
stale: `The section is present but does not carry ${drift.rule}. ${drift.consequence}`,
|
|
5388
|
+
retired: `The section still carries ${drift.rule}, which PROPOSAL-082 retired. ${drift.consequence}`
|
|
5389
|
+
}[drift.state];
|
|
5390
|
+
// Titles must differ per finding: three retired fingerprints share the
|
|
5391
|
+
// "Ceremony Scaling (Project Application)" heading, so a heading-only
|
|
5392
|
+
// title printed the same line several times with only `detail` differing.
|
|
5393
|
+
const title = {
|
|
5394
|
+
missing: `dflow/specs/shared/_conventions.md has no § ${drift.heading} section`,
|
|
5395
|
+
stale: `dflow/specs/shared/_conventions.md § ${drift.heading} is missing ${drift.rule}`,
|
|
5396
|
+
retired: `dflow/specs/shared/_conventions.md § ${drift.heading} still carries ${drift.rule}`
|
|
5397
|
+
}[drift.state];
|
|
5398
|
+
findings.push({
|
|
5399
|
+
level: drift.level,
|
|
5400
|
+
title,
|
|
5401
|
+
detail,
|
|
5402
|
+
action: 'Compare against a fresh `dflow init` in a scratch directory and copy the section across; `_conventions.md` is yours and Dflow never rewrites it. See `docs/upgrading.md`.'
|
|
5403
|
+
});
|
|
5404
|
+
}
|
|
5405
|
+
}
|
|
5406
|
+
|
|
5407
|
+
// No _overview.md machine-format check: no packaged _overview template has
|
|
5408
|
+
// ever carried `| Tech stack |` / `| Migration / legacy context |` rows, so
|
|
5409
|
+
// their absence there is the canonical state, not drift. The machine-readable
|
|
5410
|
+
// home of those two context values is the guide's "## Project Context" table
|
|
5411
|
+
// — inference reads it and checkGuideProjectContextFormat reports drift
|
|
5412
|
+
// (PROPOSAL-076).
|
|
5413
|
+
}
|
|
5414
|
+
|
|
5415
|
+
// PROPOSAL-058 direction 2 (e): template-shape drift for filled feature
|
|
5416
|
+
// dashboards. Detection + pointers only — migrating a filled document needs
|
|
5417
|
+
// judgment (old content into new sections), so the migration itself is
|
|
5418
|
+
// AI-assisted, and completed/ features are deliberately not scanned (they keep
|
|
5419
|
+
// their historical shape — BUG-001 decision).
|
|
5420
|
+
// The project's bundle version from its manifest when it is a plain `x.y.z`;
|
|
5421
|
+
// null when there is no readable manifest or the value is anything else. Both
|
|
5422
|
+
// shape checks read it: a bundle newer than this CLI means the CLI's packaged
|
|
5423
|
+
// templates are not the current ones.
|
|
5424
|
+
// The bundle's version, read with the rule `checkBundleManifestVersion` uses:
|
|
5425
|
+
// `x.y.z`, or a prerelease `x.y.z-…`. Dflow ships no prerelease, but a
|
|
5426
|
+
// hand-edited or forked manifest can carry one — and read as "no version", a
|
|
5427
|
+
// newer bundle had its docs judged against this CLI's older templates.
|
|
5428
|
+
async function readBundleVersion(cwd) {
|
|
5429
|
+
const manifestResult = await readCurrentBundleManifest(cwd);
|
|
5430
|
+
return manifestResult.kind === 'ok' && typeof manifestResult.manifest.version === 'string'
|
|
5431
|
+
&& /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(manifestResult.manifest.version) ? manifestResult.manifest.version : null;
|
|
5432
|
+
}
|
|
5433
|
+
|
|
5434
|
+
// How a bundle version compares with this CLI's: by its numeric core, so a
|
|
5435
|
+
// prerelease with a newer core is newer. (`compareVersions` would read the
|
|
5436
|
+
// patch of `0.15.1-rc.1` as 0.)
|
|
5437
|
+
function compareBundleVersion(bundleVersion) {
|
|
5438
|
+
return compareVersions(bundleVersion.replace(/-.*$/, ''), pkg.version);
|
|
5439
|
+
}
|
|
5440
|
+
|
|
5441
|
+
async function checkFeatureIndexShape(cwd, findings) {
|
|
5442
|
+
// ⚠⚠ PROPOSAL-091 gates 2 and 3. Both exits this function used to take were
|
|
5443
|
+
// the shape `checkInitOnlyStarters` has already had corrected four times, so
|
|
5444
|
+
// they are corrected here the same two ways it was.
|
|
5445
|
+
//
|
|
5446
|
+
// GATE 2 (`if (!edition) return`) — route (B), do not let the missing value
|
|
5447
|
+
// decide whether we look. An unknown edition is a fact about the PROJECT'S
|
|
5448
|
+
// SHAPE; it says nothing about whether the adopter's `_index.md` is stale. The
|
|
5449
|
+
// edition set is closed, so when the project cannot tell us, every track is a
|
|
5450
|
+
// candidate and a file is only reported when it matches NONE of them.
|
|
5451
|
+
//
|
|
5452
|
+
// GATE 3 (`catch { return }`) — route (A), and it must report. An unreadable
|
|
5453
|
+
// packaged template means the installed package is damaged, not that the
|
|
5454
|
+
// project is fine; `checkInitOnlyStarters` names this exact shape as "the
|
|
5455
|
+
// quietest state of all" and `checkWorkflowBundleSourceAndOrphans` records
|
|
5456
|
+
// that NOTHING may gate a package check. Same class, same remedy, same
|
|
5457
|
+
// wording as the sibling finding so one damaged install does not read as two
|
|
5458
|
+
// unrelated problems.
|
|
5459
|
+
const edition = await inferProjectBundleEdition(cwd);
|
|
5460
|
+
const candidateEditions = edition ? [edition] : [...BUNDLE_EDITIONS];
|
|
5461
|
+
const templates = [];
|
|
5462
|
+
const damaged = [];
|
|
5463
|
+
for (const candidate of candidateEditions) {
|
|
5464
|
+
try {
|
|
5465
|
+
templates.push({ edition: candidate, template: await readPackagedTemplate(candidate, 'templates/_index.md') });
|
|
5466
|
+
} catch (error) {
|
|
5467
|
+
damaged.push(`\`templates/${candidate}/templates/_index.md\` could not be read (${error && error.message ? error.message : error})`);
|
|
5468
|
+
}
|
|
5469
|
+
}
|
|
5470
|
+
if (damaged.length > 0) {
|
|
5471
|
+
const scope = edition
|
|
5472
|
+
? ''
|
|
5473
|
+
: ' This project does not say which track it uses, so every shipped track was checked.';
|
|
5474
|
+
findings.push({
|
|
5475
|
+
level: 'warn',
|
|
5476
|
+
title: 'The installed dflow package looks incomplete',
|
|
5477
|
+
detail: `Its packaged feature dashboard template is unusable: ${damaged.join('; ')}.${scope} This report therefore cannot say whether the \`_index.md\` files under \`dflow/specs/features/active/\` still match the current template shape.`,
|
|
5478
|
+
action: 'Reinstall dflow (e.g. `npm install -g dflow-sdd-ddd@latest`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project.'
|
|
5479
|
+
});
|
|
5480
|
+
}
|
|
5481
|
+
// ⚠⚠ SAME RULE AS `checkGuideCanonicalState`, AND IT IS HERE BECAUSE OF THE
|
|
5482
|
+
// SHAPE, NOT BECAUSE A ROUND CAUGHT IT HERE. A cross-family round reproduced
|
|
5483
|
+
// the false-dirty comparison on the guide only; this function carried the
|
|
5484
|
+
// identical `length === 0` guard, and fixing one of two identical siblings is
|
|
5485
|
+
// the exact failure this proposal documents four times over
|
|
5486
|
+
// ("the same defect was repaired four times while the one next door was left
|
|
5487
|
+
// untouched"). ⚠ It is currently unobservable HERE — both editions' packaged
|
|
5488
|
+
// `_index.md` happen to carry the same H2 set today, so a file matching one
|
|
5489
|
+
// matches the other — which makes it latent rather than absent, and latent is
|
|
5490
|
+
// why it has to be code and not a comment. The detail string is the part that
|
|
5491
|
+
// would lie first: it says "every shipped track was checked" while only the
|
|
5492
|
+
// readable one was.
|
|
5493
|
+
if (damaged.length > 0) return;
|
|
5494
|
+
// PROPOSAL-092 D4: with a bundle newer than this CLI, the packaged `_index.md`
|
|
5495
|
+
// is an older template, so comparing against it would call a current dashboard
|
|
5496
|
+
// older. `checkDocumentShapes` reports that state once, for both checks.
|
|
5497
|
+
const bundleVersion = await readBundleVersion(cwd);
|
|
5498
|
+
if (bundleVersion && compareBundleVersion(bundleVersion) > 0) return;
|
|
5499
|
+
// ⚠⚠ ENOENT AND EVERYTHING ELSE ARE DIFFERENT ANSWERS. "There is no active/
|
|
5500
|
+
// directory" means a project with no features in flight — nothing to report,
|
|
5501
|
+
// and that is the reason PROPOSAL-091's exclusion list judged this exit sound.
|
|
5502
|
+
// ⚠ But that reasoning only ever covered ENOENT: the same `catch` also
|
|
5503
|
+
// swallowed "active/ is there and cannot be listed" (a file in its place,
|
|
5504
|
+
// permissions), and doctor printed `All checks passed` over a feature set it
|
|
5505
|
+
// never looked at. The exclusion was made having read half the state space.
|
|
5506
|
+
const activeDir = path.join(cwd, 'dflow', 'specs', 'features', 'active');
|
|
5507
|
+
let entries = [];
|
|
5508
|
+
try {
|
|
5509
|
+
entries = await fs.readdir(activeDir, { withFileTypes: true });
|
|
5510
|
+
} catch (error) {
|
|
5511
|
+
if (error && error.code === 'ENOENT') return;
|
|
5512
|
+
findings.push({
|
|
5513
|
+
level: 'warn',
|
|
5514
|
+
title: 'dflow/specs/features/active/ could not be listed',
|
|
5515
|
+
detail: `It exists but this check could not read it (${error && (error.code || error.message)}), so no active feature dashboard was compared against the current template shape.`,
|
|
5516
|
+
action: 'Check that it is a directory and that its permissions allow reading, then re-run `dflow doctor`.'
|
|
5517
|
+
});
|
|
5518
|
+
return;
|
|
5519
|
+
}
|
|
5520
|
+
for (const entry of entries) {
|
|
5521
|
+
if (!entry.isDirectory()) continue;
|
|
5522
|
+
const relativePath = `dflow/specs/features/active/${entry.name}/_index.md`;
|
|
5523
|
+
// ⚠ Same split, per feature. A feature directory with no `_index.md` at all
|
|
5524
|
+
// stays silent (the approved shape); one whose `_index.md` is there and
|
|
5525
|
+
// unreadable is a check that disappeared, and says so.
|
|
5526
|
+
let content = null;
|
|
5527
|
+
let indexReadError = null;
|
|
5528
|
+
try {
|
|
5529
|
+
content = await fs.readFile(path.join(activeDir, entry.name, '_index.md'), 'utf8');
|
|
5530
|
+
} catch (error) {
|
|
5531
|
+
indexReadError = error;
|
|
5532
|
+
}
|
|
5533
|
+
if (content === null) {
|
|
5534
|
+
if (indexReadError && indexReadError.code !== 'ENOENT') {
|
|
5535
|
+
findings.push({
|
|
5536
|
+
level: 'warn',
|
|
5537
|
+
title: `${relativePath} could not be read`,
|
|
5538
|
+
detail: `It exists but this check could not read it (${indexReadError.code || indexReadError.message}), so it was not compared against the current template shape.`,
|
|
5539
|
+
action: 'Check that it is a file and that its permissions allow reading, then re-run `dflow doctor`.'
|
|
5540
|
+
});
|
|
5541
|
+
}
|
|
5542
|
+
continue;
|
|
5543
|
+
}
|
|
5544
|
+
// PROPOSAL-092 D4: a dashboard carrying a shape marker is judged by its number
|
|
5545
|
+
// in `checkDocumentShapes`. Comparing its sections here as well would report a
|
|
5546
|
+
// deliberate difference as an older shape — the one distinction the marker
|
|
5547
|
+
// exists to make. ANY marker-like line counts, readable or not: an unreadable
|
|
5548
|
+
// one is reported there as `uncertain`, and a section comparison here would put
|
|
5549
|
+
// a guess beside that honest answer.
|
|
5550
|
+
if (doctorChecks.findShapeMarkerLines(content).length > 0) continue;
|
|
5551
|
+
const perTrack = templates.map((candidate) => ({
|
|
5552
|
+
edition: candidate.edition,
|
|
5553
|
+
missing: doctorChecks.missingTemplateSections(candidate.template, content)
|
|
5554
|
+
}));
|
|
5555
|
+
// ⚠ "Missing from every candidate", not "missing from one of them". With the
|
|
5556
|
+
// track unknown this file could legitimately be either shape, so a section
|
|
5557
|
+
// present in one packaged template is not missing at all — reporting it
|
|
5558
|
+
// would assert a track this project never named.
|
|
5559
|
+
if (perTrack.some((track) => track.missing.length === 0)) continue;
|
|
5560
|
+
const detail = perTrack.length === 1
|
|
5561
|
+
? `Missing section(s) vs the current template: ${perTrack[0].missing.join(', ')}. Ignore this if you removed them on purpose.`
|
|
5562
|
+
: `This project does not say which track it uses, so every shipped track was checked and this file matches none of them — ${perTrack.map((track) => `${track.edition}: ${track.missing.join(', ')}`).join('; ')}. Ignore this if you removed them on purpose.`;
|
|
5563
|
+
findings.push({
|
|
5564
|
+
level: 'info',
|
|
5565
|
+
title: `${relativePath} looks like an older _index.md template shape`,
|
|
5566
|
+
detail,
|
|
5567
|
+
action: 'Migrate with AI assistance: give your assistant this file plus the current template (`dflow/specs/shared/dflow-workflows/templates/_index.md` after re-projecting) and merge the existing content into the new shape. Completed features can stay as-is.'
|
|
5568
|
+
});
|
|
5569
|
+
}
|
|
5570
|
+
}
|
|
5571
|
+
|
|
5572
|
+
// PROPOSAL-078 phase 1: delivery-gap detection for the table-cell formatting
|
|
5573
|
+
// convention (P-072). Scans user-authored spec surfaces — domain/,
|
|
5574
|
+
// architecture/, migration/, features/active/ + backlog/, and loose root
|
|
5575
|
+
// docs — and skips shared/ (Dflow-managed bundle, guide, conventions
|
|
5576
|
+
// machinery) plus features/completed/ (archived shape stays — same boundary
|
|
5577
|
+
// as checkFeatureIndexShape). Emits ONE aggregated info finding; doctor
|
|
5578
|
+
// never edits user-authored specs (the `_conventions.md` version-line
|
|
5579
|
+
// advance stays configure-agents' only exception, per MAINTAINERS).
|
|
5580
|
+
async function checkSpecTableConventionComment(cwd, findings) {
|
|
5581
|
+
const specsRoot = path.join(cwd, 'dflow', 'specs');
|
|
5582
|
+
const hits = [];
|
|
5583
|
+
async function walk(dir, relParts) {
|
|
5584
|
+
let entries;
|
|
5585
|
+
try {
|
|
5586
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
5587
|
+
} catch {
|
|
5588
|
+
return;
|
|
5589
|
+
}
|
|
5590
|
+
for (const entry of entries) {
|
|
5591
|
+
const rel = [...relParts, entry.name];
|
|
5592
|
+
if (entry.isDirectory()) {
|
|
5593
|
+
if (relParts.length === 0 && entry.name === 'shared') continue;
|
|
5594
|
+
if (relParts.length === 1 && relParts[0] === 'features' && entry.name === 'completed') continue;
|
|
5595
|
+
await walk(path.join(dir, entry.name), rel);
|
|
5596
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
5597
|
+
const content = await fs.readFile(path.join(dir, entry.name), 'utf8').catch(() => null);
|
|
5598
|
+
if (content === null) continue;
|
|
5599
|
+
if (doctorChecks.hasTableWithoutConventionComment(content)) {
|
|
5600
|
+
hits.push(`dflow/specs/${rel.join('/')}`);
|
|
5601
|
+
}
|
|
5602
|
+
}
|
|
5603
|
+
}
|
|
5604
|
+
}
|
|
5605
|
+
await walk(specsRoot, []);
|
|
5606
|
+
if (hits.length === 0) return;
|
|
5607
|
+
hits.sort();
|
|
5608
|
+
const shown = hits.slice(0, 5).join(', ');
|
|
5609
|
+
const more = hits.length > 5 ? ` (and ${hits.length - 5} more)` : '';
|
|
5610
|
+
findings.push({
|
|
5611
|
+
level: 'info',
|
|
5612
|
+
title: `${hits.length} spec doc(s) hold tables but lack the table-formatting convention comment`,
|
|
5613
|
+
detail: `${shown}${more}. Docs seeded before Dflow 0.13 (or written from scratch) miss the in-file reminder to keep table cells concise (\`<br>\` between short items, long narrative out of cells), so their tables tend to grow hard-to-read walls.`,
|
|
5614
|
+
// ⚠ "Only that one line", and PROPOSAL-092 is why: a template head also
|
|
5615
|
+
// carries the template's `<!-- dflow-shape: … -->` line, and copying the head
|
|
5616
|
+
// stamps the CURRENT number onto a doc of an older shape — a wrong number
|
|
5617
|
+
// doctor then trusts and stays silent about.
|
|
5618
|
+
action: 'With AI assistance, copy only the one-line `<!-- Formatting convention: keep table cells concise ... -->` comment from any current template (`dflow/specs/shared/dflow-workflows/templates/`) into each listed doc, near the top. Copy that line alone, not the template head around it: the head also carries the template\'s `<!-- dflow-shape: ... -->` line, and a doc gets its marker only through the procedure in docs/upgrading.en.md § Shape markers. Doctor never edits user-authored specs.'
|
|
5619
|
+
});
|
|
5620
|
+
}
|
|
5621
|
+
|
|
5622
|
+
// PROPOSAL-092: template shape markers. Every doc a flow creates from a template
|
|
5623
|
+
// inherits the template's `<!-- dflow-shape: {track}/{template} {n} -->` line, and
|
|
5624
|
+
// this compares that number with the CURRENT one — the number on the same line in
|
|
5625
|
+
// the template packaged with this CLI, not the project's bundle (the reference
|
|
5626
|
+
// `checkFeatureIndexShape` compares against too).
|
|
5627
|
+
//
|
|
5628
|
+
// Five outcomes, each at most ONE aggregated finding — one finding per file is the
|
|
5629
|
+
// noise PROPOSAL-078 already learned to fold (six OBTS `rules.md` files):
|
|
5630
|
+
// same number — silent: every difference is the adopter's own decision
|
|
5631
|
+
// older — info: which docs, which numbers, and what the registry says
|
|
5632
|
+
// changed in between, split into "add this" and "decide this"
|
|
5633
|
+
// newer — warn: the doc is newer than this CLI
|
|
5634
|
+
// no marker — info (D5): doctor does not judge these, and says so
|
|
5635
|
+
// unreadable — uncertain, under the PROPOSAL-084 contract
|
|
5636
|
+
// ⚠⚠ A doc without a marker is NEVER compared against a template (D7, user
|
|
5637
|
+
// 2026-09-25). Every such comparison reports placeholder-only sections, frontmatter
|
|
5638
|
+
// and replaceable sections as missing on real projects — the noise that teaches
|
|
5639
|
+
// people to skip doctor. The one-time procedure on the upgrade page is that
|
|
5640
|
+
// comparison, done once, with a person judging each difference.
|
|
5641
|
+
// ⚠ Docs on a zero-phase host that has not closed out are listed apart: every fix
|
|
5642
|
+
// these findings suggest is an edit, and a minimal host allows none outside its
|
|
5643
|
+
// closed closeout list; at closeout the docs move to `features/completed/`, which
|
|
5644
|
+
// is not scanned.
|
|
5645
|
+
const DOC_SHAPES_REGISTRY_PATH = path.join(PACKAGE_ROOT, 'lib', 'doc-shapes.json');
|
|
5646
|
+
const SHAPE_MARKERS_DOC_URL = 'https://github.com/weilung/dflow-sdd-ddd/blob/main/docs/upgrading.en.md#shape-markers';
|
|
5647
|
+
// ⚠ Every path, never "(and N more)". These findings are the only record of which
|
|
5648
|
+
// docs doctor did not judge, and the one-time procedure works through the docs a
|
|
5649
|
+
// finding NAMES — a truncated list is an unjudged doc that nothing points at.
|
|
5650
|
+
function shapeDocList(entries, render = (e) => e.display) {
|
|
5651
|
+
return entries.map(render).join(', ');
|
|
5652
|
+
}
|
|
5653
|
+
|
|
5654
|
+
// PROPOSAL-092: the registry is packaged data doctor trusts to decide what is in
|
|
5655
|
+
// scope — an entry with no paths would make its docs vanish from every finding.
|
|
5656
|
+
// So its shape is checked before anything is read through it, and a damaged entry
|
|
5657
|
+
// is reported as a damaged package rather than skipped.
|
|
5658
|
+
function docShapeRegistryProblems(registry) {
|
|
5659
|
+
const problems = [];
|
|
5660
|
+
for (const [key, e] of Object.entries(registry.templates)) {
|
|
5661
|
+
const where = `\`lib/doc-shapes.json\` entry \`${key}\``;
|
|
5662
|
+
if (!/^(greenfield|brownfield)\/[A-Za-z0-9_.-]+\.md$/.test(key)) { problems.push(`${where} is not a {track}/{template} key`); continue; }
|
|
5663
|
+
if (!e || typeof e !== 'object') { problems.push(`${where} is not an object`); continue; }
|
|
5664
|
+
if (typeof e.source !== 'string' || !e.source) problems.push(`${where} has no source`);
|
|
5665
|
+
if (!Array.isArray(e.paths) || e.paths.length === 0 || !e.paths.every((p) => typeof p === 'string' && p)) problems.push(`${where} has no usable paths`);
|
|
5666
|
+
if (!Array.isArray(e.variable) || !Array.isArray(e.conditionalFields)) problems.push(`${where} has no variable / conditionalFields lists`);
|
|
5667
|
+
const shapes = Array.isArray(e.shapes) ? e.shapes : [];
|
|
5668
|
+
if (shapes.length === 0 || !shapes.every((s, i) => s && s.number === i + 1 && typeof s.digest === 'string' && Array.isArray(s.skeleton) && Array.isArray(s.changes))) {
|
|
5669
|
+
problems.push(`${where} has no usable shapes`);
|
|
5670
|
+
}
|
|
5671
|
+
}
|
|
5672
|
+
return problems;
|
|
5673
|
+
}
|
|
5674
|
+
|
|
5675
|
+
// A change in the notes-and-order half of a shape: `>` notes and HTML comments
|
|
5676
|
+
// (added, removed, moved or `reworded`) and `reordered` headings. None of them
|
|
5677
|
+
// changes a doc's structure — PROPOSAL-092 D4's third class.
|
|
5678
|
+
function isShapeTextChange(change) {
|
|
5679
|
+
return change.kind === 'reordered' || change.kind === 'reworded'
|
|
5680
|
+
|| doctorChecks.isShapeTextItem(change.item) || doctorChecks.isShapeTextItem(change.from);
|
|
5681
|
+
}
|
|
5682
|
+
|
|
5683
|
+
function describeShapeChange(change) {
|
|
5684
|
+
const d = doctorChecks.describeShapeItem;
|
|
5685
|
+
if (change.kind === 'reordered') return `${doctorChecks.describeShapeOrder(change.parent)} changed`;
|
|
5686
|
+
if (change.kind === 'reworded') return `${d(change.to)} changed`;
|
|
5687
|
+
if (change.kind === 'added') return isShapeTextChange(change) ? `${d(change.item)} added` : d(change.item);
|
|
5688
|
+
if (change.kind === 'removed') return `${d(change.item)} removed`;
|
|
5689
|
+
if (change.kind === 'split') return `${d(change.from)} split into ${change.to.map(d).join(' + ')}`;
|
|
5690
|
+
return `${d(change.from)} ${change.kind} to ${d(change.to)}`;
|
|
5691
|
+
}
|
|
5692
|
+
|
|
5693
|
+
// Every doc under `dflow/specs/` a flow may have created from a template (D7):
|
|
5694
|
+
// `checkSpecTableConventionComment`'s boundary — `shared/` (Dflow-managed) and
|
|
5695
|
+
// frozen features skipped — plus `shared/_overview.md`, the one adopter-owned doc
|
|
5696
|
+
// under `shared/`, and with `features/` narrowed to `active/`: no flow writes a
|
|
5697
|
+
// template into `backlog/`, and `completed/` keeps its historical shape.
|
|
5698
|
+
// ⚠ A directory that cannot be listed is returned in `unreadable`, not skipped:
|
|
5699
|
+
// every doc under it goes unjudged, and saying nothing about it printed
|
|
5700
|
+
// `All checks passed` over docs doctor never opened. Only a missing
|
|
5701
|
+
// `dflow/specs/` itself is an empty scope.
|
|
5702
|
+
// ⚠ A symbolic link (a Windows junction too) is taken for what it points at, for
|
|
5703
|
+
// the same reason: skipping every entry that was not a plain file or directory
|
|
5704
|
+
// passed a linked `glossary.md` without opening it. A link that cannot be
|
|
5705
|
+
// followed is named as unread. A link back to a directory the walk is already
|
|
5706
|
+
// inside ends there instead of looping; any other alias is walked under its own
|
|
5707
|
+
// path, because its docs may sit at a flow path the other path does not.
|
|
5708
|
+
async function listShapeScopeDocuments(specsRoot) {
|
|
5709
|
+
const docs = [];
|
|
5710
|
+
const unreadable = [];
|
|
5711
|
+
const failure = (error) => (error && (error.code || error.message)) || 'unknown error';
|
|
5712
|
+
async function walk(dir, relParts, ancestors) {
|
|
5713
|
+
let entries;
|
|
5714
|
+
let real;
|
|
5715
|
+
try {
|
|
5716
|
+
real = await fs.realpath(dir);
|
|
5717
|
+
if (ancestors.has(real)) return;
|
|
5718
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
5719
|
+
} catch (error) {
|
|
5720
|
+
if (!(relParts.length === 0 && error && error.code === 'ENOENT')) {
|
|
5721
|
+
unreadable.push({ display: `dflow/specs/${relParts.join('/')}${relParts.length ? '/' : ''}`, code: failure(error) });
|
|
5722
|
+
}
|
|
5723
|
+
return;
|
|
5724
|
+
}
|
|
5725
|
+
const inside = new Set(ancestors).add(real);
|
|
5726
|
+
for (const entry of entries) {
|
|
5727
|
+
const rel = [...relParts, entry.name];
|
|
5728
|
+
// `features/` holds feature directories, and only `active/` is in scope:
|
|
5729
|
+
// a file directly under `features/` belongs to no feature, so it is out too.
|
|
5730
|
+
if (relParts.length === 1 && relParts[0] === 'features' && entry.name !== 'active') continue;
|
|
5731
|
+
let isDirectory = entry.isDirectory();
|
|
5732
|
+
let isFile = entry.isFile();
|
|
5733
|
+
if (entry.isSymbolicLink()) {
|
|
5734
|
+
try {
|
|
5735
|
+
const target = await fs.stat(path.join(dir, entry.name));
|
|
5736
|
+
isDirectory = target.isDirectory();
|
|
5737
|
+
isFile = target.isFile();
|
|
5738
|
+
} catch (error) {
|
|
5739
|
+
unreadable.push({ display: `dflow/specs/${rel.join('/')}`, code: failure(error) });
|
|
5740
|
+
continue;
|
|
5741
|
+
}
|
|
5742
|
+
}
|
|
5743
|
+
if (isDirectory) {
|
|
5744
|
+
if (relParts.length === 0 && entry.name === 'shared') continue;
|
|
5745
|
+
await walk(path.join(dir, entry.name), rel, inside);
|
|
5746
|
+
} else if (isFile && entry.name.endsWith('.md')) {
|
|
5747
|
+
docs.push(rel.join('/'));
|
|
5748
|
+
}
|
|
5749
|
+
}
|
|
5750
|
+
}
|
|
5751
|
+
await walk(specsRoot, [], new Set());
|
|
5752
|
+
// Absent is fine; a link that points nowhere is a doc that cannot be read.
|
|
5753
|
+
const overview = path.join(specsRoot, 'shared', '_overview.md');
|
|
5754
|
+
let present = false;
|
|
5755
|
+
try {
|
|
5756
|
+
const entry = await fs.lstat(overview);
|
|
5757
|
+
present = true;
|
|
5758
|
+
if (entry.isSymbolicLink()) await fs.stat(overview);
|
|
5759
|
+
docs.push('shared/_overview.md');
|
|
5760
|
+
} catch (error) {
|
|
5761
|
+
if (present || !(error && error.code === 'ENOENT')) {
|
|
5762
|
+
unreadable.push({ display: 'dflow/specs/shared/_overview.md', code: failure(error) });
|
|
5763
|
+
}
|
|
5764
|
+
}
|
|
5765
|
+
return { docs: docs.sort(), unreadable };
|
|
5766
|
+
}
|
|
5767
|
+
|
|
5768
|
+
async function checkDocumentShapes(cwd, findings) {
|
|
5769
|
+
const packageDamage = [];
|
|
5770
|
+
let registry = null;
|
|
5771
|
+
try {
|
|
5772
|
+
registry = JSON.parse(await fs.readFile(DOC_SHAPES_REGISTRY_PATH, 'utf8'));
|
|
5773
|
+
if (!registry || typeof registry.templates !== 'object' || registry.templates === null) {
|
|
5774
|
+
throw new Error('it has no "templates" object');
|
|
5775
|
+
}
|
|
5776
|
+
} catch (error) {
|
|
5777
|
+
registry = null;
|
|
5778
|
+
packageDamage.push(`\`lib/doc-shapes.json\` could not be read (${error && error.message ? error.message : error})`);
|
|
5779
|
+
}
|
|
5780
|
+
if (registry) {
|
|
5781
|
+
const problems = docShapeRegistryProblems(registry);
|
|
5782
|
+
// Coverage in the other direction, by the covered set's own rule (D3) —
|
|
5783
|
+
// every spec template under `templates/<track>/templates/`, and
|
|
5784
|
+
// `scaffolding/_overview.md` — which is the rule `test/doc-shapes.mjs` holds
|
|
5785
|
+
// the source tree to. Not by the markers: a template that lost its marker
|
|
5786
|
+
// and its entry together was found by neither, and its unmarked docs dropped
|
|
5787
|
+
// out of every finding. Any other packaged file that carries a marker needs
|
|
5788
|
+
// an entry as well.
|
|
5789
|
+
for (const track of BUNDLE_EDITIONS) {
|
|
5790
|
+
for (const sub of ['templates', 'scaffolding']) {
|
|
5791
|
+
let names;
|
|
5792
|
+
try {
|
|
5793
|
+
names = (await fs.readdir(path.join(TEMPLATE_ROOT, track, sub))).filter((n) => n.endsWith('.md'));
|
|
5794
|
+
} catch (error) {
|
|
5795
|
+
problems.push(`\`templates/${track}/${sub}/\` could not be listed (${(error && (error.code || error.message)) || 'unknown error'})`);
|
|
5796
|
+
continue;
|
|
5797
|
+
}
|
|
5798
|
+
if (sub === 'scaffolding' && !names.includes('_overview.md')) {
|
|
5799
|
+
problems.push(`\`templates/${track}/scaffolding/_overview.md\` is missing`);
|
|
5800
|
+
}
|
|
5801
|
+
for (const name of names) {
|
|
5802
|
+
const covered = sub === 'templates' || name === '_overview.md';
|
|
5803
|
+
if (!covered) {
|
|
5804
|
+
const text = await fs.readFile(path.join(TEMPLATE_ROOT, track, sub, name), 'utf8').catch(() => '');
|
|
5805
|
+
if (doctorChecks.findShapeMarkerLines(text).length === 0) continue;
|
|
5806
|
+
}
|
|
5807
|
+
const entry = registry.templates[`${track}/${name}`];
|
|
5808
|
+
if (!entry || entry.source !== `${sub}/${name}`) {
|
|
5809
|
+
problems.push(covered
|
|
5810
|
+
? `\`templates/${track}/${sub}/${name}\` is a template the shape check covers, but \`lib/doc-shapes.json\` has no entry for it`
|
|
5811
|
+
: `\`templates/${track}/${sub}/${name}\` carries a shape marker but \`lib/doc-shapes.json\` has no entry for it`);
|
|
5812
|
+
}
|
|
5813
|
+
}
|
|
5814
|
+
}
|
|
5815
|
+
}
|
|
5816
|
+
if (problems.length > 0) {
|
|
5817
|
+
packageDamage.push(...problems);
|
|
5818
|
+
registry = null;
|
|
5819
|
+
}
|
|
5820
|
+
}
|
|
5821
|
+
|
|
5822
|
+
// The current number of every template: its own marker, in THIS package.
|
|
5823
|
+
const current = new Map();
|
|
5824
|
+
for (const [key, entry] of Object.entries(registry ? registry.templates : {})) {
|
|
5825
|
+
const track = key.split('/')[0];
|
|
5826
|
+
let text;
|
|
5827
|
+
try {
|
|
5828
|
+
text = await readPackagedTemplate(track, entry.source);
|
|
5829
|
+
} catch (error) {
|
|
5830
|
+
packageDamage.push(`\`templates/${track}/${entry.source}\` could not be read (${error && error.message ? error.message : error})`);
|
|
5831
|
+
continue;
|
|
5832
|
+
}
|
|
5833
|
+
const marker = doctorChecks.readShapeMarker(text);
|
|
5834
|
+
if (marker.state !== 'ok' || `${marker.track}/${marker.template}` !== key) {
|
|
5835
|
+
packageDamage.push(`\`templates/${track}/${entry.source}\` has no readable \`${key}\` shape marker`);
|
|
5836
|
+
continue;
|
|
5837
|
+
}
|
|
5838
|
+
// The template's own number is the registry's last shape. Judging against
|
|
5839
|
+
// any other pair would invent a transition the registry cannot describe.
|
|
5840
|
+
if (marker.number !== entry.shapes.length) {
|
|
5841
|
+
packageDamage.push(`\`templates/${track}/${entry.source}\` carries shape ${marker.number}, but \`lib/doc-shapes.json\` lists ${entry.shapes.length} shape(s) for \`${key}\``);
|
|
5842
|
+
continue;
|
|
5843
|
+
}
|
|
5844
|
+
current.set(key, marker.number);
|
|
5845
|
+
}
|
|
5846
|
+
// Kept so the docs it leaves unjudged can be named once the scan has run.
|
|
5847
|
+
let packageFinding = null;
|
|
5848
|
+
if (packageDamage.length > 0) {
|
|
5849
|
+
packageFinding = {
|
|
5850
|
+
level: 'warn',
|
|
5851
|
+
title: 'The installed dflow package looks incomplete',
|
|
5852
|
+
detail: `Its template shape data is unusable: ${packageDamage.join('; ')}. This report therefore cannot say whether spec docs written from ${registry ? 'those templates' : 'any template'} are behind the current template shape.`,
|
|
5853
|
+
action: 'Reinstall dflow (e.g. `npm install -g dflow-sdd-ddd@latest`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project.'
|
|
5854
|
+
};
|
|
5855
|
+
findings.push(packageFinding);
|
|
5856
|
+
if (!registry) return;
|
|
5857
|
+
}
|
|
5858
|
+
|
|
5859
|
+
// D4: a bundle newer than this CLI means its templates are the current ones, and
|
|
5860
|
+
// this CLI's are not — judging against them would call a current doc newer.
|
|
5861
|
+
const bundleVersion = await readBundleVersion(cwd);
|
|
5862
|
+
if (bundleVersion && compareBundleVersion(bundleVersion) > 0) {
|
|
5863
|
+
findings.push({
|
|
5864
|
+
level: 'warn',
|
|
5865
|
+
title: `This project's workflow bundle comes from Dflow ${bundleVersion}, newer than this CLI (${pkg.version})`,
|
|
5866
|
+
detail: 'The template shape check compares each spec doc\'s `<!-- dflow-shape: ... -->` number with the templates packaged in this CLI, which are older than the ones this project was projected from, so it would treat an older template as the current one. Both template shape checks were skipped: the shape markers, and the section comparison of feature \`_index.md\` files without one. (Usually another branch or another machine ran a newer Dflow.)',
|
|
5867
|
+
action: 'Upgrade the CLI (e.g. `npm install -g dflow-sdd-ddd@latest`), then re-run `dflow doctor`.'
|
|
5868
|
+
});
|
|
5869
|
+
return;
|
|
5870
|
+
}
|
|
5871
|
+
const bundleOlder = Boolean(bundleVersion) && compareBundleVersion(bundleVersion) < 0;
|
|
5872
|
+
|
|
5873
|
+
const specsRoot = path.join(cwd, 'dflow', 'specs');
|
|
5874
|
+
const { docs, unreadable: unlistable } = await listShapeScopeDocuments(specsRoot);
|
|
5875
|
+
const contents = new Map();
|
|
5876
|
+
const unread = [...unlistable];
|
|
5877
|
+
for (const rel of docs) {
|
|
5878
|
+
try {
|
|
5879
|
+
contents.set(rel, await fs.readFile(path.join(specsRoot, rel), 'utf8'));
|
|
5880
|
+
} catch (error) {
|
|
5881
|
+
unread.push({ display: `dflow/specs/${rel}`, code: (error && (error.code || error.message)) || 'unknown error' });
|
|
5882
|
+
}
|
|
5883
|
+
}
|
|
5884
|
+
if (unread.length > 0) {
|
|
5885
|
+
findings.push({
|
|
5886
|
+
level: 'warn',
|
|
5887
|
+
title: `${unread.length} path(s) under dflow/specs/ could not be read, so the template shape of what they hold was not judged`,
|
|
5888
|
+
detail: `${shapeDocList(unread, (e) => `${e.display} (${e.code})`)}. Doctor could not open these, so it cannot say whether any spec doc in them is behind the current template shape — or carries a shape marker at all.`,
|
|
5889
|
+
action: 'Check that each is readable and of the right kind (a file where a file belongs, a directory where a directory belongs), then re-run `dflow doctor`.'
|
|
5890
|
+
});
|
|
5891
|
+
}
|
|
5892
|
+
// Zero-phase hosts still open: `_index.md` with an empty `## Phase Specs` table
|
|
5893
|
+
// AND no phase spec in the directory. An empty table beside a phase spec is a
|
|
5894
|
+
// host doctor cannot place (PROPOSAL-092 D5): listed apart, with both ways out.
|
|
5895
|
+
// A phase spec is whatever the registry says a phase spec's path is.
|
|
5896
|
+
const phaseSpecPaths = Object.entries(registry.templates)
|
|
5897
|
+
.filter(([key]) => key.endsWith('/phase-spec.md'))
|
|
5898
|
+
.flatMap(([, entry]) => entry.paths);
|
|
5899
|
+
const hostsWithPhaseSpec = new Set(docs
|
|
5900
|
+
.filter((rel) => phaseSpecPaths.some((p) => doctorChecks.shapePathMatches(p, rel)))
|
|
5901
|
+
.map((rel) => rel.split('/')[2]));
|
|
5902
|
+
const openMinimalHosts = new Set();
|
|
5903
|
+
const unclearHosts = new Set();
|
|
5904
|
+
for (const [rel, content] of contents) {
|
|
5905
|
+
const m = rel.match(/^features\/active\/([^/]+)\/_index\.md$/);
|
|
5906
|
+
if (!m || doctorChecks.sectionTableRowCount(content, 'Phase Specs') !== 0) continue;
|
|
5907
|
+
(hostsWithPhaseSpec.has(m[1]) ? unclearHosts : openMinimalHosts).add(m[1]);
|
|
5908
|
+
}
|
|
5909
|
+
const edition = await inferProjectBundleEdition(cwd);
|
|
5910
|
+
const templateKeys = Object.keys(registry.templates).filter((key) => !edition || key.startsWith(`${edition}/`));
|
|
5911
|
+
|
|
5912
|
+
const older = [];
|
|
5913
|
+
const newer = [];
|
|
5914
|
+
const unmarked = [];
|
|
5915
|
+
const unreadable = [];
|
|
5916
|
+
// Marked docs whose template this package cannot give a current number for:
|
|
5917
|
+
// never judged, so named in the package finding instead of skipped.
|
|
5918
|
+
const damaged = [];
|
|
5919
|
+
for (const [rel, content] of contents) {
|
|
5920
|
+
const host = rel.match(/^features\/active\/([^/]+)\//);
|
|
5921
|
+
const entry = {
|
|
5922
|
+
display: `dflow/specs/${rel}`,
|
|
5923
|
+
onOpenHost: Boolean(host) && openMinimalHosts.has(host[1]),
|
|
5924
|
+
onUnclearHost: Boolean(host) && unclearHosts.has(host[1])
|
|
5925
|
+
};
|
|
5926
|
+
const marker = doctorChecks.readShapeMarker(content);
|
|
5927
|
+
if (marker.state === 'absent') {
|
|
5928
|
+
const mapped = templateKeys.some((key) => registry.templates[key].paths.some((p) => doctorChecks.shapePathMatches(p, rel)));
|
|
5929
|
+
if (mapped) unmarked.push(entry);
|
|
5930
|
+
continue;
|
|
5931
|
+
}
|
|
5932
|
+
if (marker.state === 'unreadable') {
|
|
5933
|
+
if (marker.reason === 'multiple') entry.why = `marker lines at ${marker.lines.join(', ')}`;
|
|
5934
|
+
else if (marker.reason === 'misplaced') {
|
|
5935
|
+
entry.why = `line ${marker.lines[0]} mentions \`dflow-shape:\`, but a marker is read only from line ${marker.standard}`;
|
|
5936
|
+
// The one cause doctor can name: a doc copied whole from the projected
|
|
5937
|
+
// copy, whose first line is the bundle's own. Frontmatter under it is not
|
|
5938
|
+
// frontmatter to `dflow render` either.
|
|
5939
|
+
const firstLine = content.split(/\r?\n/)[0].replace(/^\uFEFF/, '').trim();
|
|
5940
|
+
if (firstLine === WORKFLOW_BUNDLE_GENERATED_MARKER) {
|
|
5941
|
+
entry.why += `; line 1 is \`${WORKFLOW_BUNDLE_GENERATED_MARKER}\`, copied from the template's projected copy — delete it and the blank line after it`;
|
|
5942
|
+
}
|
|
5943
|
+
// Frontmatter behind a byte-order mark is not frontmatter to render
|
|
5944
|
+
// either, so the marker under it is not on its line.
|
|
5945
|
+
if (marker.bomHidesFrontmatter) {
|
|
5946
|
+
entry.why += '; the file starts with a byte-order mark (BOM), which hides its frontmatter from `dflow render` too — save it as UTF-8 without a BOM';
|
|
5947
|
+
}
|
|
5948
|
+
}
|
|
5949
|
+
else entry.why = `line ${marker.lines[0]} is not a well-formed marker`;
|
|
5950
|
+
unreadable.push(entry);
|
|
5951
|
+
continue;
|
|
5952
|
+
}
|
|
5953
|
+
const key = `${marker.track}/${marker.template}`;
|
|
5954
|
+
if (!registry.templates[key]) {
|
|
5955
|
+
entry.why = `line ${marker.line} names \`${key}\`, which this CLI does not ship`;
|
|
5956
|
+
unreadable.push(entry);
|
|
5957
|
+
continue;
|
|
5958
|
+
}
|
|
5959
|
+
const now = current.get(key);
|
|
5960
|
+
if (now === undefined) { damaged.push(entry); continue; }
|
|
5961
|
+
if (marker.number === now) continue;
|
|
5962
|
+
if (marker.number > now) newer.push({ ...entry, key, number: marker.number, now });
|
|
5963
|
+
else older.push({ ...entry, key, from: marker.number, to: now });
|
|
5964
|
+
}
|
|
5965
|
+
|
|
5966
|
+
if (packageFinding && damaged.length > 0) {
|
|
5967
|
+
packageFinding.detail += ` Not judged because of it: ${shapeDocList(damaged)}.`;
|
|
5968
|
+
}
|
|
5969
|
+
|
|
5970
|
+
const apart = (e) => e.onOpenHost || e.onUnclearHost;
|
|
5971
|
+
// A doc on an open minimal host must not be touched before closeout (D5), so
|
|
5972
|
+
// an action that says "edit these docs" says first which ones it leaves out —
|
|
5973
|
+
// the detail naming them "leave alone" while the action said "add markers"
|
|
5974
|
+
// gave the adopter two opposite instructions.
|
|
5975
|
+
const hostAction = (entries) => {
|
|
5976
|
+
const parts = [];
|
|
5977
|
+
if (entries.some((e) => e.onOpenHost)) {
|
|
5978
|
+
parts.push('Leave the docs listed as not yet closed out alone: at closeout they drop out of this check.');
|
|
5979
|
+
}
|
|
5980
|
+
if (entries.some((e) => e.onUnclearHost)) {
|
|
5981
|
+
parts.push('For the docs whose feature doctor cannot place, first decide whether the feature is a minimal host; if it is, leave them alone too.');
|
|
5982
|
+
}
|
|
5983
|
+
return parts.length === 0 ? '' : `${parts.join(' ')} For every other doc: `;
|
|
5984
|
+
};
|
|
5985
|
+
const hostNote = (entries) => {
|
|
5986
|
+
const render = (e) => e.why ? `${e.display} (${e.why})` : e.display;
|
|
5987
|
+
const onHost = entries.filter((e) => e.onOpenHost);
|
|
5988
|
+
const unclear = entries.filter((e) => e.onUnclearHost);
|
|
5989
|
+
return [
|
|
5990
|
+
onHost.length === 0 ? '' : ` Not yet closed out, so leave these alone for now — a minimal host allows no change outside its closeout list, and at closeout they move to features/completed/ and drop out of this check: ${shapeDocList(onHost, render)}.`,
|
|
5991
|
+
unclear.length === 0 ? '' : ` Doctor cannot tell whether these belong to a minimal host that is still open — the feature's \`_index.md\` lists no phase under \`## Phase Specs\`, yet its directory holds a phase spec: ${shapeDocList(unclear, render)}. If the feature is a minimal host, leave them alone until closeout, like an open host; if it is not, handle them like the rest${entries === older ? ' (they are listed with the rest above)' : ''}.`
|
|
5992
|
+
].join('');
|
|
5993
|
+
};
|
|
5994
|
+
|
|
5995
|
+
if (older.length > 0) {
|
|
5996
|
+
const groups = new Map();
|
|
5997
|
+
// A doc on a host doctor cannot place stays in its group: if the feature is
|
|
5998
|
+
// not a minimal host, what changed is exactly what the adopter needs. The
|
|
5999
|
+
// host note names it again, with the other way out.
|
|
6000
|
+
for (const e of older.filter((x) => !x.onOpenHost)) {
|
|
6001
|
+
const g = `${e.key} ${e.from} → ${e.to}`;
|
|
6002
|
+
if (!groups.has(g)) groups.set(g, { ...e, docs: [] });
|
|
6003
|
+
groups.get(g).docs.push(e);
|
|
6004
|
+
}
|
|
6005
|
+
const described = [...groups.entries()].map(([label, g]) => {
|
|
6006
|
+
const changes = registry.templates[g.key].shapes
|
|
6007
|
+
.filter((s) => s.number > g.from && s.number <= g.to)
|
|
6008
|
+
.flatMap((s) => s.changes || []);
|
|
6009
|
+
const structural = changes.filter((c) => !isShapeTextChange(c));
|
|
6010
|
+
const add = structural.filter((c) => c.kind === 'added').map(describeShapeChange);
|
|
6011
|
+
const decide = structural.filter((c) => c.kind !== 'added').map(describeShapeChange);
|
|
6012
|
+
const text = changes.filter(isShapeTextChange).map(describeShapeChange);
|
|
6013
|
+
const parts = [
|
|
6014
|
+
add.length ? `added — ${add.join('; ')}` : '',
|
|
6015
|
+
decide.length ? `renamed, split, moved or removed — ${decide.join('; ')}` : '',
|
|
6016
|
+
text.length ? `notes, comments and section order, which do not change the doc's structure — ${text.join('; ')}` : ''
|
|
6017
|
+
].filter(Boolean).join('. ');
|
|
6018
|
+
return `\`${label}\` (${shapeDocList(g.docs)}): ${parts || 'the registry lists no change'}.`;
|
|
6019
|
+
});
|
|
6020
|
+
const overview = older.some((e) => e.key.endsWith('/_overview.md'))
|
|
6021
|
+
? ' `_overview.md` is not projected into dflow-workflows/: compare it with `templates/<track>/scaffolding/_overview.md` in the installed package, or a fresh `dflow init` in a scratch directory.'
|
|
6022
|
+
: '';
|
|
6023
|
+
findings.push({
|
|
6024
|
+
level: 'info',
|
|
6025
|
+
title: `${older.length} spec doc(s) were written against an older template shape`,
|
|
6026
|
+
detail: `${described.length > 0 ? described.join(' ') : 'None outside the minimal hosts listed next.'}${hostNote(older)}`,
|
|
6027
|
+
action: `${hostAction(older)}${bundleOlder ? 'Run `dflow configure-agents` first, so the templates under dflow-workflows/ are the current ones. ' : ''}For each ADDED item, add it to the doc — the section, the column or the frontmatter field; give existing rows \`{TBD}\` and leave a comment above the table saying what the value is, when to backfill it and how to tell it is done. Add the shape, not the content. Renamed, split, moved and removed items are reported only: decide how to adapt the doc yourself, because filling them in blindly leaves the old and the new section side by side. Notes, comments and section order do not change the doc's structure: compare them with the current template and decide, with your AI assistant, whether to bring the doc in line — keeping yours is a valid answer. When a doc is handled — added to, brought in line, or a difference kept on purpose — change the number on its \`<!-- dflow-shape: ... -->\` line to the current one.${overview} Doctor never edits user-authored specs.`
|
|
6028
|
+
});
|
|
6029
|
+
}
|
|
6030
|
+
|
|
6031
|
+
if (newer.length > 0) {
|
|
6032
|
+
findings.push({
|
|
6033
|
+
level: 'warn',
|
|
6034
|
+
title: `${newer.length} spec doc(s) carry a shape number newer than this CLI knows`,
|
|
6035
|
+
detail: `${shapeDocList(newer, (e) => `${e.display} (\`${e.key}\` ${e.number}; this CLI: ${e.now})`)}. Usually another branch or another machine used a newer Dflow; this CLI cannot say what those numbers mean.`,
|
|
6036
|
+
action: 'Upgrade the CLI (e.g. `npm install -g dflow-sdd-ddd@latest`), then re-run `dflow doctor`.'
|
|
6037
|
+
});
|
|
6038
|
+
}
|
|
6039
|
+
|
|
6040
|
+
if (unmarked.length > 0) {
|
|
6041
|
+
const open = unmarked.filter((e) => !apart(e));
|
|
6042
|
+
const listed = open.length > 0 ? `${shapeDocList(open)}.` : 'None outside the minimal hosts listed below.';
|
|
6043
|
+
const editionNote = edition
|
|
6044
|
+
? ''
|
|
6045
|
+
: ' This project does not say which track it uses (greenfield or brownfield), and a marker names one: confirm the track before adding markers.';
|
|
6046
|
+
findings.push({
|
|
6047
|
+
level: 'info',
|
|
6048
|
+
title: `${unmarked.length} spec doc(s) have no shape marker, so doctor does not judge their template shape`,
|
|
6049
|
+
detail: `${listed} Without the \`<!-- dflow-shape: ... -->\` line a doc takes from its template, doctor cannot tell a template that changed from a change you made on purpose, so it says nothing about these docs' shape rather than guess. Only docs at the paths Dflow's flows create them at are counted; a renamed or moved doc is not.${editionNote}${hostNote(unmarked)}`,
|
|
6050
|
+
action: `${hostAction(unmarked)}Add the markers once, with AI assistance: compare each doc with the current template, keep what you changed on purpose, add what the template added later (shape, not content), decide yourself how to adapt what it renamed, split, moved or removed, then give the doc the template's marker line with the current number. The procedure: ${SHAPE_MARKERS_DOC_URL} (offline copy: docs/upgrading.en.md in the installed package).`
|
|
6051
|
+
});
|
|
6052
|
+
}
|
|
6053
|
+
|
|
6054
|
+
if (unreadable.length > 0) {
|
|
6055
|
+
const open = unreadable.filter((e) => !apart(e));
|
|
6056
|
+
findings.push(uncertainFinding({
|
|
6057
|
+
id: SHAPE_MARKER_UNREADABLE_ID,
|
|
6058
|
+
title: `${unreadable.length} spec doc(s) have a shape marker doctor cannot read`,
|
|
6059
|
+
detail: `${open.length > 0 ? `${shapeDocList(open, (e) => `${e.display} (${e.why})`)}. ` : ''}A doc needs exactly one line mentioning \`dflow-shape:\` — its marker, on the first line (or, after frontmatter, on the line right after the closing \`---\`), of the form \`<!-- dflow-shape: {track}/{template} {number} -->\` (anything after the number is optional) naming a template this CLI ships. Doctor does not judge whether a marker-like line anywhere else — in a list, a quote, a comment or a code block — is live, and does not guess what a damaged line meant.${hostNote(unreadable)}`,
|
|
6060
|
+
affects: ['the template shape check for these docs — whether each one is behind the current template shape'],
|
|
6061
|
+
action: `${hostAction(unreadable)}Leave exactly one marker line in each doc, at that position. A marker quoted as an example, or an old one commented out, counts too: reword it so it no longer contains \`dflow-shape:\`. If you know which template and number the doc had, write the line back with that number (the line in the installed template carries the current number, which would mark an older doc as current); if you are not sure, treat the doc as having no marker and follow ${SHAPE_MARKERS_DOC_URL} (offline copy: docs/upgrading.en.md in the installed package).`
|
|
6062
|
+
}));
|
|
6063
|
+
}
|
|
6064
|
+
}
|
|
6065
|
+
|
|
6066
|
+
// PROPOSAL-058: root agent shim manageability. Only existing files are
|
|
6067
|
+
// classified — which agents a project uses is not recorded, so an absent file is
|
|
6068
|
+
// not a finding; a file with no Dflow content at all is the user's own business.
|
|
6069
|
+
async function checkRootAgentShims(cwd, findings) {
|
|
6070
|
+
for (const agent of ['agents', 'claude', 'copilot']) {
|
|
6071
|
+
const target = getAiAgentTarget(agent);
|
|
6072
|
+
const raw = await fs.readFile(path.join(cwd, target.relativePath), 'utf8').catch(() => null);
|
|
6073
|
+
if (raw === null) continue;
|
|
6074
|
+
const lf = toLf(raw);
|
|
6075
|
+
// AGENTS.md can carry a second managed pair (the Codex command-trigger
|
|
6076
|
+
// block); a malformed trigger pair blocks `--command-adapters` trigger
|
|
6077
|
+
// management (snippet fallback) even when the agent-shim block is healthy.
|
|
6078
|
+
if (agent === 'agents') {
|
|
6079
|
+
const triggerRegion = classifyMarkedRegion(lf, CODEX_TRIGGER_SECTION_START, CODEX_TRIGGER_SECTION_END);
|
|
6080
|
+
if (triggerRegion.state === 'malformed') {
|
|
6081
|
+
findings.push({
|
|
6082
|
+
level: 'warn',
|
|
6083
|
+
title: `${target.relativePath} has malformed Dflow command-trigger markers`,
|
|
6084
|
+
detail: '`dflow configure-agents --command-adapters` cannot manage the trigger block and falls back to a merge snippet.',
|
|
6085
|
+
action: 'Remove the stray `<!-- dflow-generated: codex-command-triggers ... -->` markers, then re-run `dflow configure-agents --command-adapters`.'
|
|
6086
|
+
});
|
|
6087
|
+
}
|
|
6088
|
+
}
|
|
6089
|
+
const region = classifyMarkedRegion(lf, AGENT_SHIM_SECTION_START, AGENT_SHIM_SECTION_END);
|
|
6090
|
+
if (region.state === 'malformed') {
|
|
6091
|
+
findings.push({
|
|
6092
|
+
level: 'warn',
|
|
6093
|
+
title: `${target.relativePath} has malformed Dflow markers`,
|
|
6094
|
+
detail: '`dflow configure-agents` cannot manage the Dflow block and falls back to a merge snippet.',
|
|
6095
|
+
action: 'Remove the stray Dflow markers, then re-run `dflow configure-agents`.'
|
|
6096
|
+
});
|
|
6097
|
+
continue;
|
|
6098
|
+
}
|
|
6099
|
+
if (region.state === 'present') continue; // marker-managed: refreshed in place
|
|
6100
|
+
const baseShim = buildAiAgentShim(target.relativePath);
|
|
6101
|
+
if (isPristineDflowAgentsShim(raw, baseShim, target.relativePath)) continue; // regenerated in place
|
|
6102
|
+
if (contentReferencesAiAgentGuide(raw)) {
|
|
6103
|
+
findings.push({
|
|
6104
|
+
level: 'info',
|
|
6105
|
+
title: `${target.relativePath} references the Dflow guide but is not Dflow-managed`,
|
|
6106
|
+
detail: 'Dflow wording inside it stays frozen on upgrade (no markers, and not a pristine Dflow shim).',
|
|
6107
|
+
action: 'Re-run `dflow configure-agents` on an interactive terminal and accept the managed-block offer, then remove any older Dflow wording you keep outside the block.'
|
|
6108
|
+
});
|
|
6109
|
+
}
|
|
6110
|
+
}
|
|
6111
|
+
}
|
|
6112
|
+
|
|
6113
|
+
// PROPOSAL-091 problem 1, route R3 (user decision, 2026-08-29). Until this
|
|
6114
|
+
// check shipped, NOTHING in doctor read a single path under `.claude/`,
|
|
6115
|
+
// `.agents/` or `.github/prompts/`: an adopter could delete the entire command
|
|
6116
|
+
// adapter and skill layer and doctor's output did not change by one byte.
|
|
6117
|
+
//
|
|
6118
|
+
// ⚠⚠ THE NAME IS PART OF THE SPEC. Three of the seven rows below judge SKILL
|
|
6119
|
+
// files, and the skill row is the only one in this whole proposal with a
|
|
6120
|
+
// measured instance behind it (OBTS on `develop`: three `SKILL.md` files still
|
|
6121
|
+
// carrying the `0.14.0` description while `doctor` said `All checks passed`).
|
|
6122
|
+
// Calling this `checkCommandAdapters` is how the skill half gets dropped.
|
|
6123
|
+
//
|
|
6124
|
+
// ⚠⚠ IT ONLY JUDGES FILES THAT ARE ALREADY THERE. "One adapter and no skill at
|
|
6125
|
+
// all" is SILENT, deliberately, and that silence is a residual risk this
|
|
6126
|
+
// proposal accepted rather than an oversight — it is disclosed to adopters in
|
|
6127
|
+
// `docs/doctor-uncertainty.md` § "Shapes that are known and deliberately not
|
|
6128
|
+
// reported". Three attempts to detect absence were each specified and each
|
|
6129
|
+
// found to misfire on a legitimate population: `PROPOSAL-058` records that
|
|
6130
|
+
// "which agents a project uses is not recorded, so an absent file is not a
|
|
6131
|
+
// finding", and `PROPOSAL-037` (user-approved, 2026-05-22) actively RECOMMENDS
|
|
6132
|
+
// that adopters gitignore the adapters and regenerate them after a clone. Both
|
|
6133
|
+
// of the states doctor would have to separate — "I never wanted them" and "I
|
|
6134
|
+
// had them and they went" — look identical on disk. The superseding fix is a
|
|
6135
|
+
// product decision, not a better heuristic: `command-adapters-install-by-default`
|
|
6136
|
+
// in `planning/opt-in-backlog.md`. If that lands, revisit this comment and the
|
|
6137
|
+
// disclosure page together.
|
|
6138
|
+
//
|
|
6139
|
+
// ⚠ PER-AGENT, NOT PER-PROJECT, AND THE THREE AGENTS ARE NOT THE SAME SHAPE:
|
|
6140
|
+
// claude — `.claude/commands/dflow/<id>.md`, its own directory.
|
|
6141
|
+
// copilot — `.github/prompts/dflow-<id>.prompt.md`, a SHARED namespace, so
|
|
6142
|
+
// the `dflow-` glob is the unit and the directory is not.
|
|
6143
|
+
// codex — has NO command adapter at all (`addCommandAdapterItems` has only
|
|
6144
|
+
// the claude and copilot branches; `PROPOSAL-037` § 6 is the
|
|
6145
|
+
// user-approved decision not to generate them, so that absence is
|
|
6146
|
+
// canonical). ⚠⚠ But codex DOES have a skill: `SKILL_ADAPTER_TARGETS`
|
|
6147
|
+
// keys it `agents`, at `.agents/skills/dflow/SKILL.md`. Writing
|
|
6148
|
+
// "codex is silent" wholesale deletes a third of the coverage of the
|
|
6149
|
+
// one row that has a measurement behind it.
|
|
6150
|
+
async function checkAdapterAndSkillState(cwd, findings) {
|
|
6151
|
+
// --- Rows 1 and 6: command adapters, for the two agents that have them ---
|
|
6152
|
+
const commandSurfaces = [
|
|
6153
|
+
{
|
|
6154
|
+
label: '`.claude/commands/dflow/`',
|
|
6155
|
+
relativePathFor: (id) => `.claude/commands/dflow/${id}.md`,
|
|
6156
|
+
flag: '--command-adapters'
|
|
6157
|
+
},
|
|
6158
|
+
{
|
|
6159
|
+
label: '`.github/prompts/dflow-*.prompt.md`',
|
|
6160
|
+
relativePathFor: (id) => `.github/prompts/dflow-${id}.prompt.md`,
|
|
6161
|
+
flag: '--command-adapters'
|
|
6162
|
+
}
|
|
6163
|
+
];
|
|
6164
|
+
for (const surface of commandSurfaces) {
|
|
6165
|
+
const present = [];
|
|
6166
|
+
const missing = [];
|
|
6167
|
+
const notFiles = [];
|
|
6168
|
+
for (const id of EXPECTED_COMMAND_IDS) {
|
|
6169
|
+
// ⚠⚠ `pathExists` IS TOO WEAK FOR THIS ROW, and the gap was a silent pass
|
|
6170
|
+
// of exactly the kind this check exists to close: it answers "is there
|
|
6171
|
+
// something at this path", while the row is about command adapter FILES.
|
|
6172
|
+
// A directory named `status.md` counted as installed, doctor printed
|
|
6173
|
+
// `All checks passed`, and `configure-agents --command-adapters` then died
|
|
6174
|
+
// EISDIR partway through rewriting the surface. Three states, not two.
|
|
6175
|
+
// (`addLegacyCommandAdapterCleanupItems` already stats and refuses to act
|
|
6176
|
+
// on a non-file for the same reason — this row just had not caught up.)
|
|
6177
|
+
const relativePath = surface.relativePathFor(id);
|
|
6178
|
+
// eslint-disable-next-line no-await-in-loop
|
|
6179
|
+
const stats = await fs.stat(path.join(cwd, relativePath)).catch(() => null);
|
|
6180
|
+
if (stats === null) missing.push(id);
|
|
6181
|
+
else if (stats.isFile()) present.push(id);
|
|
6182
|
+
else notFiles.push(relativePath);
|
|
6183
|
+
}
|
|
6184
|
+
if (notFiles.length > 0) {
|
|
6185
|
+
const shown = notFiles.slice(0, 3).join(', ');
|
|
6186
|
+
const more = notFiles.length > 3 ? ` (and ${notFiles.length - 3} more)` : '';
|
|
6187
|
+
findings.push({
|
|
6188
|
+
level: 'warn',
|
|
6189
|
+
title: `${notFiles.length} Dflow command adapter path(s) are not files`,
|
|
6190
|
+
detail: `${shown}${more}. Something other than a command adapter file occupies these paths, so this surface cannot be used or regenerated as it stands.`,
|
|
6191
|
+
action: `Remove or rename them, then run \`dflow configure-agents ${surface.flag}\`. Left in place, that command fails partway through rewriting this surface.`
|
|
6192
|
+
});
|
|
6193
|
+
}
|
|
6194
|
+
// Row 5: not one of them is there. Silent — see the header comment.
|
|
6195
|
+
if (present.length === 0) continue;
|
|
6196
|
+
if (missing.length === 0) continue;
|
|
6197
|
+
findings.push({
|
|
6198
|
+
level: 'warn',
|
|
6199
|
+
title: `${surface.label} has ${present.length} of ${EXPECTED_COMMAND_IDS.length} Dflow command adapters`,
|
|
6200
|
+
// ⚠ Say both readings. Doctor cannot separate "I deleted the ones I do not
|
|
6201
|
+
// use" from "the last generation was interrupted" — the files look the
|
|
6202
|
+
// same either way — and this is the noisier level of the two, so the
|
|
6203
|
+
// wording has to carry the ambiguity the level does not.
|
|
6204
|
+
detail: `Missing: ${missing.join(', ')}. Either some were removed on purpose, or a generation run did not finish; the files on disk cannot tell doctor which.`,
|
|
6205
|
+
action: `Run \`dflow configure-agents ${surface.flag}\` to regenerate the full set, or ignore this if you removed them deliberately.`
|
|
6206
|
+
});
|
|
6207
|
+
}
|
|
6208
|
+
|
|
6209
|
+
// --- Row 2: 0.5.0-era filenames left behind ---
|
|
6210
|
+
// Derived from LEGACY_COMMAND_ADAPTERS rather than restated, so a future entry
|
|
6211
|
+
// is picked up here without a second list to keep in sync. Reported on the
|
|
6212
|
+
// file's own evidence: unlike the cleanup path in `configure-agents`, doctor
|
|
6213
|
+
// has no selected-agent list to gate on, and does not need one — the file
|
|
6214
|
+
// being there IS the finding.
|
|
6215
|
+
for (const legacy of LEGACY_COMMAND_ADAPTERS) {
|
|
6216
|
+
const stale = [];
|
|
6217
|
+
for (const command of legacy.commands) {
|
|
6218
|
+
const relativePath = legacy.pathPattern.replace('<id>', command.id);
|
|
6219
|
+
// ⚠ Files only, same reason as the row above, and here it also keeps the
|
|
6220
|
+
// finding's own wording true: it calls these "command adapters using the
|
|
6221
|
+
// 0.5.0 filename", which is a claim about a file. A directory of that name
|
|
6222
|
+
// is not one, and `configure-agents` would not remove it either — it stats
|
|
6223
|
+
// and declines on a non-file.
|
|
6224
|
+
// eslint-disable-next-line no-await-in-loop
|
|
6225
|
+
const stats = await fs.stat(path.join(cwd, relativePath)).catch(() => null);
|
|
6226
|
+
if (stats && stats.isFile()) stale.push(relativePath);
|
|
6227
|
+
}
|
|
6228
|
+
if (stale.length === 0) continue;
|
|
6229
|
+
const shown = stale.slice(0, 3).join(', ');
|
|
6230
|
+
const more = stale.length > 3 ? ` (and ${stale.length - 3} more)` : '';
|
|
6231
|
+
findings.push({
|
|
6232
|
+
level: 'warn',
|
|
6233
|
+
title: `${stale.length} command adapter(s) still use the Dflow ${legacy.version} filename`,
|
|
6234
|
+
detail: `${shown}${more}. Dflow ${legacy.version} wrote \`${legacy.pathPattern}\`; the current layout is \`${legacy.pathPattern.replace(`dflow-<id>`, '<id>')}\`, so these are duplicates that shadow nothing and can drift from the command registry.`,
|
|
6235
|
+
action: `Run \`dflow configure-agents --command-adapters\`, which removes the ones it recognises as Dflow-generated and names any it will not touch.`
|
|
6236
|
+
});
|
|
6237
|
+
}
|
|
6238
|
+
|
|
6239
|
+
// --- Rows 3, 4 and 7: skill files, all three agents alike ---
|
|
6240
|
+
// ⚠ The packaged skill is read ONLY once a marker-bearing project skill has
|
|
6241
|
+
// been found, so a project with no skill at all cannot be told its install is
|
|
6242
|
+
// broken — and a project that HAS one is never told nothing at all. The
|
|
6243
|
+
// `catch { return }` this avoids is the shape `checkInitOnlyStarters` records
|
|
6244
|
+
// as "the quietest state of all".
|
|
6245
|
+
let packagedSkill = null;
|
|
6246
|
+
let packagedSkillError = null;
|
|
6247
|
+
for (const [agent, target] of Object.entries(SKILL_ADAPTER_TARGETS)) {
|
|
6248
|
+
// ⚠⚠ ABSENT AND UNREADABLE ARE DIFFERENT STATES, and one `catch(() => null)`
|
|
6249
|
+
// collapsed them. Row 5's silence is about a file that is NOT THERE; a file
|
|
6250
|
+
// that IS there and cannot be read is the INV-1 shape — something to check
|
|
6251
|
+
// exists, and the check disappeared. `checkInitOnlyStarters` splits exactly
|
|
6252
|
+
// these two apart, for exactly this reason, 400 lines up in this same file;
|
|
6253
|
+
// this loop was the one place that did not. Narrow in practice (invalid
|
|
6254
|
+
// UTF-8 does not reach here — `readFile(…,'utf8')` substitutes U+FFFD — so
|
|
6255
|
+
// it takes a permission or type error), which is an argument about how often
|
|
6256
|
+
// it fires, not about whether the silence is wrong.
|
|
6257
|
+
// ⚠ THIS SPLIT IS NOT APPLIED EVERYWHERE. Four checks this proposal did not
|
|
6258
|
+
// touch — `checkGuideSectionRefs`, `checkSpecTableConventionComment`,
|
|
6259
|
+
// `checkRootAgentShims`, `checkWorkflowBundleSourceAndOrphans` — still fold
|
|
6260
|
+
// an unreadable path into the absent branch and report nothing at all
|
|
6261
|
+
// (measured: each prints `All checks passed`). Declined by the maintainer
|
|
6262
|
+
// 2026-08-30 as a user-system permission/lock condition rather than
|
|
6263
|
+
// something Dflow claims to guard, and recorded as
|
|
6264
|
+
// `doctor-unreadable-vs-absent-sweep` in `planning/opt-in-backlog.md`.
|
|
6265
|
+
// ⚠ If you ever come to make those four consistent, this is the shape to
|
|
6266
|
+
// copy — and that row is where the reasoning for leaving them lives.
|
|
6267
|
+
const skillPath = path.join(cwd, target.relativePath);
|
|
6268
|
+
let existing = null;
|
|
6269
|
+
let unreadable = false;
|
|
6270
|
+
try {
|
|
6271
|
+
existing = await fs.readFile(skillPath, 'utf8');
|
|
6272
|
+
} catch (error) {
|
|
6273
|
+
if (error && error.code !== 'ENOENT') {
|
|
6274
|
+
unreadable = true;
|
|
6275
|
+
findings.push({
|
|
6276
|
+
level: 'warn',
|
|
6277
|
+
title: `${target.relativePath} could not be read`,
|
|
6278
|
+
detail: `It exists but this check could not read it (${error.code || error.message}), so nothing can be said about whether it is the skill this Dflow version projects.`,
|
|
6279
|
+
action: 'Check the path\'s permissions and that it is a file rather than a directory, then re-run `dflow doctor`.'
|
|
6280
|
+
});
|
|
6281
|
+
}
|
|
6282
|
+
}
|
|
6283
|
+
if (unreadable) continue;
|
|
6284
|
+
// Row 5 again: no skill file for this agent. Silent.
|
|
6285
|
+
if (existing === null) continue;
|
|
6286
|
+
// Row 4: no Dflow marker means the overwrite guard in `addSkillAdapterItems`
|
|
6287
|
+
// already treats it as the user's own file and leaves it alone. Doctor has
|
|
6288
|
+
// no business reporting drift against a template it is not the source of.
|
|
6289
|
+
if (!existing.includes(SKILL_ADAPTER_GENERATED_MARKER)) continue;
|
|
6290
|
+
if (packagedSkill === null && packagedSkillError === null) {
|
|
6291
|
+
// ⚠⚠ "READABLE" IS NOT "USABLE", AND THIS ONE HAD TEETH. The read-error
|
|
6292
|
+
// branch alone is only half the rule `checkGuideCanonicalState` follows:
|
|
6293
|
+
// there, a packaged template that is readable but structurally unusable
|
|
6294
|
+
// (no marker region) is ALSO reported as package damage. Here a packaged
|
|
6295
|
+
// skill of zero bytes, or one with the marker stripped, sailed past — and
|
|
6296
|
+
// then every comparison below failed, so doctor told a project whose three
|
|
6297
|
+
// `SKILL.md` files this very CLI had just projected that they "no longer
|
|
6298
|
+
// match this version". That is a false statement about the reader's files
|
|
6299
|
+
// (INV-2), with the blame pointed the wrong way: the sibling check would
|
|
6300
|
+
// have said "a problem with the installed package, not with anything in
|
|
6301
|
+
// your project".
|
|
6302
|
+
//
|
|
6303
|
+
// ⚠⚠ And the action it printed was destructive, which is what sets the
|
|
6304
|
+
// severity. Measured end to end: with the packaged skill emptied, doctor
|
|
6305
|
+
// said "Run `dflow configure-agents --skills` to regenerate it", and doing
|
|
6306
|
+
// exactly that overwrote all three project files 1925 -> 0 bytes and
|
|
6307
|
+
// exited 0. Doctor's advice destroyed the files it had just misdescribed.
|
|
6308
|
+
// ⚠⚠ DECODE STRICTLY, the way `readPackagedTemplate` decodes every OTHER
|
|
6309
|
+
// packaged file. `buildDflowSkillAdapter` is a plain
|
|
6310
|
+
// `readFile(..., 'utf8')`, which never fails: invalid bytes become U+FFFD
|
|
6311
|
+
// and the string sails through "non-empty" and "has the marker". Measured:
|
|
6312
|
+
// four invalid bytes appended to the packaged skill made doctor tell a
|
|
6313
|
+
// project whose three `SKILL.md` files it had just projected that they
|
|
6314
|
+
// "no longer match this version", and following the printed action
|
|
6315
|
+
// rewrote all three with replacement characters (1925 -> 1935 bytes,
|
|
6316
|
+
// exit 0). Emptiness and a missing marker were only two of the ways this
|
|
6317
|
+
// file can be unusable; corruption was the third and the loudest.
|
|
6318
|
+
let candidate = null;
|
|
6319
|
+
try {
|
|
6320
|
+
const buffer = await fs.readFile(path.join(TEMPLATE_ROOT, COMMON_SKILL_SOURCE_REL));
|
|
6321
|
+
candidate = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
6322
|
+
} catch (error) {
|
|
6323
|
+
packagedSkillError = error;
|
|
6324
|
+
}
|
|
6325
|
+
// A projected skill is defined by its marker — the same token row 4 uses to
|
|
6326
|
+
// decide a file is Dflow's rather than the user's — so a source without it
|
|
6327
|
+
// cannot be the thing we compare against.
|
|
6328
|
+
const usable = candidate !== null
|
|
6329
|
+
&& candidate.trim().length > 0
|
|
6330
|
+
&& candidate.includes(SKILL_ADAPTER_GENERATED_MARKER);
|
|
6331
|
+
if (usable) {
|
|
6332
|
+
packagedSkill = candidate;
|
|
6333
|
+
} else {
|
|
6334
|
+
if (packagedSkillError === null) packagedSkillError = new Error('unusable');
|
|
6335
|
+
const cause = candidate === null
|
|
6336
|
+
? `could not be read as UTF-8 text (${packagedSkillError && packagedSkillError.message ? packagedSkillError.message : packagedSkillError})`
|
|
6337
|
+
: (candidate.trim().length === 0
|
|
6338
|
+
? 'is empty'
|
|
6339
|
+
: 'carries no `dflow-generated: skill-adapter` marker');
|
|
6340
|
+
findings.push({
|
|
6341
|
+
level: 'warn',
|
|
6342
|
+
title: 'The installed dflow package looks incomplete',
|
|
6343
|
+
// ⚠ The action below is the ONLY defence against this. The write side
|
|
6344
|
+
// still projects an unusable packaged skill verbatim and exits 0 —
|
|
6345
|
+
// declined by the maintainer 2026-08-30 (it takes an already-damaged
|
|
6346
|
+
// install) and recorded as `configure-agents-writes-empty-skill` in
|
|
6347
|
+
// `planning/opt-in-backlog.md`. So this wording is load-bearing:
|
|
6348
|
+
// whoever reads it is the last thing standing between a broken
|
|
6349
|
+
// package and three overwritten `SKILL.md` files.
|
|
6350
|
+
detail: `Its packaged skill source ${cause}. This report therefore cannot say whether the Dflow-generated \`SKILL.md\` files in this project are current — and \`dflow configure-agents --skills\` would project this same unusable content over them.`,
|
|
6351
|
+
action: 'Reinstall dflow (e.g. `npm install -g dflow-sdd-ddd@latest`, or re-link your local checkout), and do NOT run `configure-agents --skills` until you have. This is a problem with the installed package, not with anything in your project.'
|
|
6352
|
+
});
|
|
6353
|
+
}
|
|
6354
|
+
}
|
|
6355
|
+
if (packagedSkill === null) continue;
|
|
6356
|
+
// Row 3 (and row 7, which is this row applied to codex). The `description`
|
|
6357
|
+
// frontmatter in this file is the text a tool matches to decide whether to
|
|
6358
|
+
// engage at all, so a stale copy is not a cosmetic lag — it is the natural
|
|
6359
|
+
// language entry point still using the previous release's boundary.
|
|
6360
|
+
if (doctorChecks.matchesTemplateWithPlaceholders(existing, packagedSkill)) continue;
|
|
6361
|
+
findings.push({
|
|
6362
|
+
level: 'info',
|
|
6363
|
+
title: `${target.relativePath} differs from the skill this CLI projects`,
|
|
6364
|
+
detail: 'It carries the Dflow skill marker, so Dflow generated it, and its content no longer matches this version. Its `description` frontmatter is what the tool matches on to auto-engage, so a stale copy keeps an older trigger boundary.',
|
|
6365
|
+
action: `Run \`dflow configure-agents --skills\` to regenerate it (\`--skills\` is required: a flagless run does not refresh a skill that already exists). ${agent === 'agents' ? 'This is the Codex skill path.' : ''}`.trim()
|
|
6366
|
+
});
|
|
6367
|
+
}
|
|
6368
|
+
}
|
|
6369
|
+
|
|
6370
|
+
// PROPOSAL-058 direction 2 (c) companion: the bundle manifest records which
|
|
6371
|
+
// Dflow version last projected the workflow bundle.
|
|
6372
|
+
async function checkBundleManifestVersion(cwd, findings) {
|
|
6373
|
+
const manifestResult = await readCurrentBundleManifest(cwd);
|
|
6374
|
+
// ⚠⚠ PROPOSAL-091 gate 7 — disclosure, and the two non-ok kinds are NOT one
|
|
6375
|
+
// state. `readCurrentBundleManifest` already separates them and
|
|
6376
|
+
// `configure-agents` already acts on that split (it reports the file and
|
|
6377
|
+
// degrades on corrupt), so a single `kind !== 'ok'` return here made doctor
|
|
6378
|
+
// the only one of the three that could not tell them apart. ABSENT is the
|
|
6379
|
+
// legal state of a project whose bundle has never been projected — nothing to
|
|
6380
|
+
// report. CORRUPT is damage, and it silently switches off both this check and
|
|
6381
|
+
// the manifest half of `inferProjectBundleEdition`; saying nothing about it is
|
|
6382
|
+
// the silent pass this proposal exists to close.
|
|
6383
|
+
// ⚠ A manifest that IS readable but whose `version` field is absent or
|
|
6384
|
+
// malformed still returns silently below. Deliberate, not an oversight:
|
|
6385
|
+
// `buildBundleManifest` has always written the field, so reaching that state
|
|
6386
|
+
// takes a hand edit of a Dflow-managed file. Declined by the maintainer
|
|
6387
|
+
// 2026-08-30 and recorded as `p091-manifest-version-field-absent` in
|
|
6388
|
+
// `planning/opt-in-backlog.md`, with what would reopen it.
|
|
6389
|
+
if (manifestResult.kind === 'absent') return;
|
|
6390
|
+
if (manifestResult.kind === 'corrupt') {
|
|
6391
|
+
findings.push({
|
|
6392
|
+
level: 'warn',
|
|
6393
|
+
title: `${WORKFLOW_BUNDLE_MANIFEST_PATH} could not be read`,
|
|
6394
|
+
detail: `${manifestResult.reason}. This check therefore cannot say which Dflow version projected the workflow bundle, and the edition the manifest records is unavailable to every check that asks for it. \`dflow configure-agents\` reports the same file and degrades: it skips stale cleanup and leaves the manifest untouched.`,
|
|
6395
|
+
action: `Delete ${WORKFLOW_BUNDLE_MANIFEST_PATH} and run \`dflow configure-agents\` to rebuild it from the current bundle.`
|
|
6396
|
+
});
|
|
6397
|
+
return;
|
|
6398
|
+
}
|
|
6399
|
+
const version = manifestResult.manifest.version;
|
|
6400
|
+
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(version) || version === pkg.version) return;
|
|
6401
|
+
if (compareVersions(version, pkg.version) < 0) {
|
|
6402
|
+
findings.push({
|
|
6403
|
+
level: 'info',
|
|
6404
|
+
title: `Workflow bundle was projected by Dflow ${version}; this CLI is ${pkg.version}`,
|
|
6405
|
+
detail: 'The references/ and templates/ files under dflow-workflows/ are from the older version.',
|
|
6406
|
+
action: 'Run `dflow configure-agents` to re-project the bundle.'
|
|
6407
|
+
});
|
|
6408
|
+
}
|
|
6409
|
+
}
|
|
6410
|
+
|
|
3237
6411
|
// PROPOSAL-052 (c): read-only mop-up for the manifest-orphan edge. A
|
|
3238
6412
|
// Dflow-generated bundle file that is no longer in the current package source
|
|
3239
6413
|
// can linger if it was retired before generalized stale-removal shipped, or the
|
|
@@ -3243,19 +6417,185 @@ async function checkConventionsDflowVersion(cwd, findings) {
|
|
|
3243
6417
|
// deleted by hand. Doctor detects and reports such files read-only (never
|
|
3244
6418
|
// deletes). Detection requires a directory scan because, by definition, the
|
|
3245
6419
|
// manifest no longer lists the orphan — but a read-only scan is safe here.
|
|
3246
|
-
async function
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
6420
|
+
async function checkWorkflowBundleSourceAndOrphans(cwd, findings) {
|
|
6421
|
+
// ⚠ NOTHING GATES THE PACKAGE CHECK, and EVERY shipped edition is checked. Four
|
|
6422
|
+
// repairs guessed at a boundary here before it was accepted that there isn't one —
|
|
6423
|
+
// see the comment on the `for (const e of dependedOn)` loop below for what each
|
|
6424
|
+
// guess let through. The last guess was the subtlest: checking only the project's
|
|
6425
|
+
// OWN edition, which is exactly enough to answer "will this block me" and not
|
|
6426
|
+
// enough to answer "is my install damaged". Those are different questions and the
|
|
6427
|
+
// report now answers both.
|
|
3250
6428
|
const edition = await inferProjectBundleEdition(cwd);
|
|
3251
|
-
|
|
6429
|
+
// Needed before the catch, not after: the "your retired-file scan was lost" clause
|
|
6430
|
+
// may only be said where that scan was actually going to run (`projgate-y1` F5).
|
|
6431
|
+
const bundleDir = path.join(cwd, WORKFLOW_BUNDLE_DEST);
|
|
6432
|
+
const bundleExists = await pathExists(bundleDir);
|
|
6433
|
+
|
|
6434
|
+
// ⚠⚠ TWO RESOLVERS, AND THEY DO NOT AGREE. `inferProjectBundleEdition` prefers the
|
|
6435
|
+
// manifest (authoritative for what was PROJECTED, which is what the orphan scan
|
|
6436
|
+
// below compares against). `configure-agents` resolves by STRUCTURE alone. On a
|
|
6437
|
+
// project where they disagree — manifest says brownfield, the tree looks greenfield
|
|
6438
|
+
// — doctor would name a track as unused while `configure-agents` was about to read
|
|
6439
|
+
// exactly that track and hard-fail on it. `projgate-sol1` executed it: doctor said
|
|
6440
|
+
// "does not use", configure exited 1 on the same damage. That is the original
|
|
6441
|
+
// false-clean rebuilt through a different mechanism, and it is a claim this file
|
|
6442
|
+
// only started making an hour earlier.
|
|
6443
|
+
// So the project is treated as depending on ONE edition only when BOTH resolvers
|
|
6444
|
+
// name the same one. Anything else — disagreement, or either resolver silent — and
|
|
6445
|
+
// every edition counts as depended-upon. Conservative, and the cost is only that a
|
|
6446
|
+
// damaged unused track reads as `warn` instead of `info` on an already-inconsistent
|
|
6447
|
+
// project.
|
|
6448
|
+
const structureEdition = await inferExistingEdition(cwd);
|
|
6449
|
+
const resolvedEdition = edition && edition === structureEdition ? edition : null;
|
|
6450
|
+
const dependedOn = resolvedEdition ? [resolvedEdition] : [...BUNDLE_EDITIONS];
|
|
6451
|
+
const unused = BUNDLE_EDITIONS.filter((e) => !dependedOn.includes(e));
|
|
3252
6452
|
|
|
3253
6453
|
let sourceFiles;
|
|
3254
6454
|
try {
|
|
3255
|
-
|
|
3256
|
-
|
|
6455
|
+
// ⚠⚠ THIS LOOP IS THE PACKAGE INTEGRITY CHECK, and the comment below is the only
|
|
6456
|
+
// record of why it has the shape it has. It used to live on a named function that a
|
|
6457
|
+
// later repair left unreachable; `projgate-y2r` caught that the pointer above landed
|
|
6458
|
+
// on dead code a routine sweep would delete. Keep it attached to code that RUNS.
|
|
6459
|
+
//
|
|
6460
|
+
// ⚠⚠ THE HISTORY MATTERS, because three separate repairs each moved this boundary
|
|
6461
|
+
// instead of removing it, and each one was reported as a fix. `projgate-x1`: the
|
|
6462
|
+
// check sat behind "is a bundle projected". `projgate-x2`: behind "is an edition
|
|
6463
|
+
// inferable". `projgate-y1`: a common-tree-only scan, which reached exactly ONE of
|
|
6464
|
+
// the three integrity asserts — `assertEditionBundleComplete` never ran, and
|
|
6465
|
+
// `assertNoBundleCollision` over one tree is close to a no-op, since `references/x`
|
|
6466
|
+
// and `templates/x` cannot key-collide and one readdir cannot return two same-case
|
|
6467
|
+
// names. So a cross-tree collision or a gutted edition tree still produced
|
|
6468
|
+
// `All checks passed` beside a `configure-agents` that hard-failed on the same
|
|
6469
|
+
// package. There is nothing to infer: the edition set is CLOSED and both trees
|
|
6470
|
+
// always ship, so when the project cannot tell us, check them all.
|
|
6471
|
+
//
|
|
6472
|
+
// ⚠⚠ ONE PER EDITION, NEVER BOTH IN ONE LIST — and this is the part that looks like
|
|
6473
|
+
// a style choice and is not. `assertNoBundleCollision` keys on `sourceRel` and
|
|
6474
|
+
// deliberately NOT on `sourceRoot`, because its job is to catch one destination path
|
|
6475
|
+
// claimed twice. Merging greenfield and brownfield into a single list therefore
|
|
6476
|
+
// reports every same-named per-track file as a collision, which on the real tree is
|
|
6477
|
+
// every flow reference there is. Measured on the healthy tree: the merged form
|
|
6478
|
+
// throws on `references/drift-verification.md` — a FALSE POSITIVE shipped to every
|
|
6479
|
+
// adopter, strictly worse than the defect it was meant to fix. `projgate-y1` found
|
|
6480
|
+
// the gap correctly and prescribed exactly that merge; the prescription was checked
|
|
6481
|
+
// before it was applied, which is the only reason it is not in this file.
|
|
6482
|
+
for (const e of dependedOn) {
|
|
6483
|
+
const files = await listBundleSourceFiles(e);
|
|
6484
|
+
if (e === edition) sourceFiles = files;
|
|
6485
|
+
}
|
|
6486
|
+
} catch (error) {
|
|
6487
|
+
// ⚠⚠ This catch used to be bare (`catch { return; }`) and it was a false-clean
|
|
6488
|
+
// generator: the three source-integrity asserts inside listBundleSourceFiles
|
|
6489
|
+
// (collision, complete edition tree, complete common tree) fire on a broken
|
|
6490
|
+
// INSTALL, and swallowing them skipped the orphan scan in silence — so `doctor`
|
|
6491
|
+
// printed `All checks passed` and exited 0 on a package whose `configure-agents`
|
|
6492
|
+
// hard-fails before writing a byte. Reproduced end-to-end by `feedbackcommon-xv6`
|
|
6493
|
+
// (2026-08-10): delete one required common file and the two commands disagree
|
|
6494
|
+
// completely. Pre-existing, not introduced by the common-tree single-sourcing.
|
|
6495
|
+
//
|
|
6496
|
+
// ⚠⚠ THE FIRST VERSION OF THIS FIX SAT BELOW THE BUNDLE-DIRECTORY CHECK AND
|
|
6497
|
+
// LEFT HALF THE DEFECT IN PLACE (`projgate-x1`, 2026-08-11). The reasoning then
|
|
6498
|
+
// was: `All checks passed` is a claim about the PROJECT, so a directory with no
|
|
6499
|
+
// projected bundle has abandoned no claim and deserves no warning. That
|
|
6500
|
+
// conflated two different states — a bare directory that is not a Dflow project
|
|
6501
|
+
// at all, and an INITIALIZED project whose bundle is missing. The second is a
|
|
6502
|
+
// Dflow project, doctor does make a claim about it, and the reviewer reproduced
|
|
6503
|
+
// exactly that: delete the project's bundle directory, break the package, and
|
|
6504
|
+
// the old order printed `All checks passed` again. The repair then moved the
|
|
6505
|
+
// gate to "can an edition be inferred" — and `projgate-x2` reached THAT state
|
|
6506
|
+
// too (strip the manifest plus every structural signal). Two wrong guesses at
|
|
6507
|
+
// the same boundary is the signal this repo records as a design question rather
|
|
6508
|
+
// than a third patch — and the third guess (a common-tree-only scan) was wrong
|
|
6509
|
+
// too, for a reason only the other model family saw — see the comment on the
|
|
6510
|
+
// `for (const e of dependedOn)` loop above. The packaged source is now validated
|
|
6511
|
+
// unconditionally, every edition of it.
|
|
6512
|
+
//
|
|
6513
|
+
// ⚠ Level is `warn`, not PROPOSAL-084 `uncertain`. Nothing here is uncertain: a
|
|
6514
|
+
// named assert fired and the remedy is unambiguous. `warn` already suppresses
|
|
6515
|
+
// `All checks passed` (see printDoctorReport), so `uncertain` would buy no extra
|
|
6516
|
+
// honesty while putting a broken install onto an explainer page written for
|
|
6517
|
+
// unreadable PROJECT shapes — and would drag in the id + affects + bilingual
|
|
6518
|
+
// page contract for a state the reader cannot act on differently.
|
|
6519
|
+
//
|
|
6520
|
+
// ⚠ Nothing is rethrown. An unexpected read error must not turn a read-only
|
|
6521
|
+
// health check into a non-zero exit, but it must not vanish either — that is
|
|
6522
|
+
// the same silent-skip class, just with a different trigger.
|
|
6523
|
+
// ⚠ The "and therefore the scan did not run" clause may only be said where that
|
|
6524
|
+
// scan was actually going to run: it needs BOTH an inferred edition and a
|
|
6525
|
+
// projected bundle to compare against. A doctor message that reports a loss which
|
|
6526
|
+
// did not occur is the same class of false operational claim this whole fix
|
|
6527
|
+
// exists to remove — one notch smaller, which is why the first version got the
|
|
6528
|
+
// edition half right and the bundle half wrong (`projgate-y1` F5).
|
|
6529
|
+
const scanLost = edition && bundleExists
|
|
6530
|
+
? ' The retired-bundle-file scan did not run, so this report cannot say whether your project holds retired bundle files.'
|
|
6531
|
+
: '';
|
|
6532
|
+
if (error instanceof InitError) {
|
|
6533
|
+
// ⚠ "configure-agents will fail on this package too" is only TRUE when we know
|
|
6534
|
+
// which edition THAT command will resolve to. Two rounds narrowed this: with no
|
|
6535
|
+
// edition at all the damage may sit in a tree the project never reads
|
|
6536
|
+
// (`projgate-x3`), and with the two resolvers disagreeing we do not know which
|
|
6537
|
+
// tree it will read (`projgate-sol1`). Both collapse into the same test — say
|
|
6538
|
+
// it flatly only when the resolvers agree. Say what is known, not what sounds
|
|
6539
|
+
// decisive.
|
|
6540
|
+
const alsoFails = resolvedEdition
|
|
6541
|
+
? '`dflow configure-agents` will fail on this package too.'
|
|
6542
|
+
: 'Whether `dflow configure-agents` also fails depends on which track it resolves this project to.';
|
|
6543
|
+
findings.push({
|
|
6544
|
+
level: 'warn',
|
|
6545
|
+
title: 'The installed dflow package looks incomplete',
|
|
6546
|
+
detail: `An integrity check on its packaged workflow bundle source failed: ${error.message}${scanLost}`,
|
|
6547
|
+
action: `Reinstall dflow (e.g. \`npm install -g dflow-sdd-ddd@latest\`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project — ${alsoFails}`
|
|
6548
|
+
});
|
|
6549
|
+
} else {
|
|
6550
|
+
findings.push({
|
|
6551
|
+
level: 'warn',
|
|
6552
|
+
title: 'Could not read the packaged workflow bundle source',
|
|
6553
|
+
detail: `Reading it failed: ${error && error.message ? error.message : error}.${scanLost}`,
|
|
6554
|
+
action: 'Check that the installed dflow package is readable (permissions, an interrupted install), then re-run `dflow doctor`.'
|
|
6555
|
+
});
|
|
6556
|
+
}
|
|
3257
6557
|
return;
|
|
3258
6558
|
}
|
|
6559
|
+
|
|
6560
|
+
// ⚠ DISCLOSE, DO NOT BLOCK (user decision, 2026-08-11). A track this project does
|
|
6561
|
+
// not use can be damaged without anything here ever failing — `projgate-x3`
|
|
6562
|
+
// measured it: a greenfield project on a package with a gutted brownfield tree has
|
|
6563
|
+
// `doctor` and `configure-agents` BOTH succeeding, and both are right, because that
|
|
6564
|
+
// project never reads that tree. So this is not the false-clean the `warn` above
|
|
6565
|
+
// exists for; the two commands agree. What was wrong was only that the report
|
|
6566
|
+
// implied a completeness it had not checked.
|
|
6567
|
+
// The honest answer is a different question with a different level: "will this
|
|
6568
|
+
// block you" is `warn`, "is your install damaged" is `info`. Raising this to `warn`
|
|
6569
|
+
// would re-create the original disagreement in the opposite direction — doctor
|
|
6570
|
+
// saying broken while `configure-agents` runs fine — which trades one false claim
|
|
6571
|
+
// for another rather than removing it.
|
|
6572
|
+
// ⚠ Reached only when the loop above did NOT throw: if the track you depend on is
|
|
6573
|
+
// broken, "reinstall" already covers everything and a second finding is noise.
|
|
6574
|
+
for (const other of unused) {
|
|
6575
|
+
try {
|
|
6576
|
+
await listBundleSourceFiles(other);
|
|
6577
|
+
} catch (error) {
|
|
6578
|
+
findings.push({
|
|
6579
|
+
level: 'info',
|
|
6580
|
+
title: `The installed dflow package is damaged in the ${other} track, which this project does not use`,
|
|
6581
|
+
detail: `${error && error.message ? error.message : error}`,
|
|
6582
|
+
// ⚠ `resolvedEdition`, not `edition`: this sentence is only reachable when both
|
|
6583
|
+
// resolvers agreed, and naming the manifest's answer here when they had not
|
|
6584
|
+
// would be the same false claim one line further down.
|
|
6585
|
+
// ⚠ The install is shared. A global `dflow` serves every project on this
|
|
6586
|
+
// machine, so "does not block THIS project" is not "harmless" — said without
|
|
6587
|
+
// the second sentence it invites a reader to leave a damaged install in place
|
|
6588
|
+
// for a sibling project that does use that track (`projgate-sol1` ITEM 2.1).
|
|
6589
|
+
action: `Nothing here blocks this project — it uses the ${resolvedEdition} track, and neither \`dflow init\` nor \`dflow configure-agents\` reads the ${other} source tree for it. But this install is shared: any other project on this machine that uses the ${other} track will hit it. Reinstall dflow to clear it — and do so before you switch this project to the ${other} track.`
|
|
6590
|
+
});
|
|
6591
|
+
}
|
|
6592
|
+
}
|
|
6593
|
+
|
|
6594
|
+
// Everything below is the ORPHAN SCAN, and it is the only part that needs both an
|
|
6595
|
+
// edition (to know the full source set) and a projected bundle (to have something
|
|
6596
|
+
// to compare against). Package integrity was settled above, independent of both.
|
|
6597
|
+
if (!edition || !bundleExists) return;
|
|
6598
|
+
|
|
3259
6599
|
const sourceRel = new Set(sourceFiles.map((f) => `${WORKFLOW_BUNDLE_DEST}/${f.sourceRel}`));
|
|
3260
6600
|
|
|
3261
6601
|
for (const dir of ['references', 'templates']) {
|
|
@@ -3297,27 +6637,136 @@ async function inferProjectBundleEdition(cwd) {
|
|
|
3297
6637
|
return inferExistingEdition(cwd);
|
|
3298
6638
|
}
|
|
3299
6639
|
|
|
6640
|
+
// PROPOSAL-084. The page is a mutable `blob/main` pointer, so it inherits
|
|
6641
|
+
// PROPOSAL-081's evergreen contract (MAINTAINERS.md § README Language Strategy):
|
|
6642
|
+
// it must stay usable by every published CLI that prints this line. The English
|
|
6643
|
+
// page is the runtime target and the zh page is one language-switch away, which
|
|
6644
|
+
// is the precedent `docs/upgrading.en.md` already set.
|
|
6645
|
+
const DOCTOR_UNCERTAINTY_DOC_URL = 'https://github.com/weilung/dflow-sdd-ddd/blob/main/docs/doctor-uncertainty.en.md';
|
|
6646
|
+
|
|
6647
|
+
// PROPOSAL-084: the only way to build an `uncertain` finding. It exists so the
|
|
6648
|
+
// two fields the state MEANS cannot be dropped by a future caller copying the
|
|
6649
|
+
// bare `findings.push({ level, title, detail, action })` shape used everywhere
|
|
6650
|
+
// else in this file — the stable id (invariant 3) and the checks whose silence
|
|
6651
|
+
// must not be read as a pass (invariant 2). Omitting either throws here, in our
|
|
6652
|
+
// code, instead of rendering `undefined` into a user's health report. A crash
|
|
6653
|
+
// over a silently wrong answer is this subsystem's standing trade (see
|
|
6654
|
+
// `parseContextLine`'s type check for the same call).
|
|
6655
|
+
function uncertainFinding({ id, title, detail, affects, action }) {
|
|
6656
|
+
if (!id || !Array.isArray(affects) || affects.length === 0) {
|
|
6657
|
+
throw new Error(`PROPOSAL-084: an uncertain finding needs a stable id and a non-empty affects list (got id=${JSON.stringify(id)}, affects=${JSON.stringify(affects)})`);
|
|
6658
|
+
}
|
|
6659
|
+
return { level: 'uncertain', id, title, detail, affects, action };
|
|
6660
|
+
}
|
|
6661
|
+
|
|
3300
6662
|
function printDoctorReport(stdout, cwd, findings) {
|
|
3301
6663
|
stdout.write(`Dflow Doctor ${pkg.version}\n`);
|
|
3302
6664
|
stdout.write(`Project: ${cwd}\n\n`);
|
|
3303
6665
|
|
|
3304
|
-
|
|
6666
|
+
const uncertain = findings.filter((f) => f.level === 'uncertain');
|
|
6667
|
+
const assessed = findings.filter((f) => f.level !== 'uncertain');
|
|
6668
|
+
|
|
6669
|
+
// ⚠⚠ PROPOSAL-084 INVARIANT 1 — this branch is the whole proposal.
|
|
6670
|
+
// `All checks passed` is a claim about the WHOLE project. An uncertain finding
|
|
6671
|
+
// says a part of it could not be read at all, so the claim is not available —
|
|
6672
|
+
// not "available with a caveat printed underneath". The reviewer that was asked
|
|
6673
|
+
// to argue AGAINST this proposal made that the condition of its worth: a
|
|
6674
|
+
// disclosure the user can skim past just moves the silent pass from a wrong
|
|
6675
|
+
// `current` to an unread warning.
|
|
6676
|
+
//
|
|
6677
|
+
// ⚠ BE EXACT ABOUT WHAT THIS SPELLING BUYS, because the first version of this
|
|
6678
|
+
// comment overclaimed and the probe caught it. Against the ORIGINAL
|
|
6679
|
+
// `findings.length === 0` the guard is not stronger — it is logically the same
|
|
6680
|
+
// test, since `findings` is the union of the two arrays, and no fixture can
|
|
6681
|
+
// separate them. What it defends is the refactor this very function just made
|
|
6682
|
+
// tempting: now that `assessed` exists and reads like "the real findings", the
|
|
6683
|
+
// natural shorthand here is `assessed.length === 0`, and THAT would print a
|
|
6684
|
+
// clean bill of health over a project whose only finding is uncertain — the
|
|
6685
|
+
// precise false claim this proposal exists to delete. `test/upgrade-drift.mjs`
|
|
6686
|
+
// pins it with a fixture whose findings are uncertain and nothing else, which
|
|
6687
|
+
// is the case that distinguishes those two, and the pin was verified to go red
|
|
6688
|
+
// against the `assessed.length === 0` mutation rather than assumed to.
|
|
6689
|
+
if (uncertain.length === 0 && assessed.length === 0) {
|
|
3305
6690
|
stdout.write('All checks passed. No Dflow health findings detected.\n');
|
|
6691
|
+
writeAdapterScopeNote(stdout);
|
|
6692
|
+
stdout.write('Doctor is read-only and does not modify any files.\n');
|
|
3306
6693
|
return;
|
|
3307
6694
|
}
|
|
3308
6695
|
|
|
3309
|
-
for (const finding of
|
|
6696
|
+
for (const finding of assessed) {
|
|
3310
6697
|
stdout.write(`[${finding.level}] ${finding.title}\n`);
|
|
3311
6698
|
stdout.write(` ${finding.detail}\n`);
|
|
3312
6699
|
stdout.write(` ${finding.action}\n\n`);
|
|
3313
6700
|
}
|
|
3314
6701
|
|
|
6702
|
+
for (const finding of uncertain) {
|
|
6703
|
+
stdout.write(`[uncertain] ${finding.title} (${finding.id})\n`);
|
|
6704
|
+
stdout.write(` ${finding.detail}\n`);
|
|
6705
|
+
// ⚠ INVARIANT 2, and it is doing more work than it looks like. Doctor reports
|
|
6706
|
+
// by exception: a check that emits nothing reads as "that part is current".
|
|
6707
|
+
// For the checks below, that silence is precisely what has stopped being
|
|
6708
|
+
// trustworthy, so naming them converts an absence the user would have
|
|
6709
|
+
// misread into a statement they can act on. Without this line the feature
|
|
6710
|
+
// degrades into "something somewhere may be wrong".
|
|
6711
|
+
// ⚠⚠ "Not evaluated" WAS A FALSE STATEMENT (`p084-xv6` finding 1). The affected
|
|
6712
|
+
// checks still run — a user can see a `[warn]` from a check this very report
|
|
6713
|
+
// called unevaluated, which is a contradiction printed at the exact boundary
|
|
6714
|
+
// this feature exists to be honest about. What is true is weaker and is what
|
|
6715
|
+
// this now says: they ran, and neither their silence nor their output can be
|
|
6716
|
+
// trusted while the shape is present.
|
|
6717
|
+
stdout.write(` Ran, but cannot be trusted while this shape is present — their silence is NOT a pass, and anything they DO report may be an artefact of the shape: ${finding.affects.join('; ')}.\n`);
|
|
6718
|
+
stdout.write(` ${finding.action}\n\n`);
|
|
6719
|
+
}
|
|
6720
|
+
|
|
3315
6721
|
const counts = { warn: 0, info: 0 };
|
|
3316
6722
|
for (const f of findings) counts[f.level] = (counts[f.level] || 0) + 1;
|
|
3317
|
-
|
|
6723
|
+
const uncertainTally = counts.uncertain ? `, ${counts.uncertain} uncertain` : '';
|
|
6724
|
+
stdout.write(`${findings.length} finding(s): ${counts.warn} warn, ${counts.info} info${uncertainTally}.\n`);
|
|
6725
|
+
if (uncertain.length > 0) {
|
|
6726
|
+
stdout.write(`\nThis report is INCOMPLETE. Dflow found ${uncertain.length} shape(s) it is known to read unreliably, so the results of the checks named above — including their silence — cannot be trusted.\n`);
|
|
6727
|
+
stdout.write(`What each id means, and how to rewrite around it: ${DOCTOR_UNCERTAINTY_DOC_URL} (offline copy: docs/doctor-uncertainty.en.md in the installed package).\n`);
|
|
6728
|
+
}
|
|
6729
|
+
writeAdapterScopeNote(stdout);
|
|
3318
6730
|
stdout.write('Doctor is read-only and does not modify any files.\n');
|
|
3319
6731
|
}
|
|
3320
6732
|
|
|
6733
|
+
// PROPOSAL-091 (d) — user decision, 2026-08-29. Two rules, and MAINTAINERS.md
|
|
6734
|
+
// § Rule Type requires both to be labelled for what they are:
|
|
6735
|
+
//
|
|
6736
|
+
// BOUNDARY — doctor states what it cannot judge. `checkAdapterAndSkillState`
|
|
6737
|
+
// reports only on adapter and skill files that already exist;
|
|
6738
|
+
// whether this project SHOULD have them is intent, and intent is
|
|
6739
|
+
// recorded nowhere on disk.
|
|
6740
|
+
// DELEGATION — it names who picks the question up: the AI running Dflow, which
|
|
6741
|
+
// unlike doctor can simply ask.
|
|
6742
|
+
//
|
|
6743
|
+
// ⚠⚠ THE PARENTHESIS IS LOAD-BEARING AND MUST NOT BE DROPPED. Without it this
|
|
6744
|
+
// reads as a gate — "doctor does not check it, but the AI does, so it is
|
|
6745
|
+
// covered" — and nothing enforces that the AI checks, nor verifies afterwards
|
|
6746
|
+
// that it did. Describing a delegation as a guarantee is a defect this repo has
|
|
6747
|
+
// already paid to remove.
|
|
6748
|
+
//
|
|
6749
|
+
// ⚠ It prints on every run, and that placement is the whole reason it is cheap:
|
|
6750
|
+
// the moment someone asks "is my install complete?" IS the moment doctor is
|
|
6751
|
+
// running. The alternatives considered and rejected were the flow files (every
|
|
6752
|
+
// feature run pays) and `AI-AGENT-GUIDE.md` (every session pays).
|
|
6753
|
+
function writeAdapterScopeNote(stdout) {
|
|
6754
|
+
// ⚠⚠ "their absence is never a finding here" WAS FALSE, and it was printed
|
|
6755
|
+
// directly beneath a finding that contradicted it: with 10 of 11 adapters on
|
|
6756
|
+
// disk, the same report names the missing one. Absence of a WHOLE surface is
|
|
6757
|
+
// what goes unreported; absence of some members of a surface already in use is
|
|
6758
|
+
// reported, and has to be, or the partial-set row would not exist. A scope note
|
|
6759
|
+
// that overstates its own silence is the same defect as a check that overstates
|
|
6760
|
+
// its own coverage — this note exists to be exact about the boundary.
|
|
6761
|
+
// ⚠ "reported above" was wrong for the same reason the sentence before it was:
|
|
6762
|
+
// this note is fixed text on every run, so any claim it makes about THIS
|
|
6763
|
+
// report is false on the runs where that finding is not present. It is a
|
|
6764
|
+
// statement of policy; keep it one.
|
|
6765
|
+
stdout.write('\nScope: doctor does not judge whether this project SHOULD have the `/dflow:*` command files or a Dflow skill file — that is intent, and nothing records it. It judges only surfaces already in use: a partly installed set is reported, while a surface with nothing installed at all is passed over in silence rather than reported as missing.\n');
|
|
6766
|
+
stdout.write('If you are unsure, ask the AI you run Dflow with to check this project once against the command list in AI-AGENT-GUIDE.md.\n');
|
|
6767
|
+
stdout.write('(That is a delegation, not a guarantee: nothing makes it happen, and nothing verifies that it did.)\n');
|
|
6768
|
+
}
|
|
6769
|
+
|
|
3321
6770
|
module.exports = {
|
|
3322
6771
|
runConfigureAgents,
|
|
3323
6772
|
runDoctor,
|
|
@@ -3331,7 +6780,34 @@ module.exports = {
|
|
|
3331
6780
|
writeFilePlan,
|
|
3332
6781
|
// Exported for tests (PROPOSAL-064): pure bundle-source guards, unit-tested on
|
|
3333
6782
|
// synthetic descriptor lists without touching the packaged templates/ tree.
|
|
6783
|
+
// REQUIRED_COMMON_BUNDLE_FILES rides along so the tests can derive their
|
|
6784
|
+
// fixtures from the real list instead of restating it: a hardcoded copy goes
|
|
6785
|
+
// stale the moment a file joins the list, and it fails as a confusing
|
|
6786
|
+
// assertion error in the *positive* case rather than as missing coverage.
|
|
6787
|
+
REQUIRED_COMMON_BUNDLE_FILES,
|
|
3334
6788
|
assertNoBundleCollision,
|
|
3335
6789
|
assertEditionBundleComplete,
|
|
3336
|
-
assertCommonBundleComplete
|
|
6790
|
+
assertCommonBundleComplete,
|
|
6791
|
+
// Exported for tests (PROPOSAL-076): context inference reads the guide's
|
|
6792
|
+
// Project Context rows, but its only write consumer is whole-guide creation
|
|
6793
|
+
// when the guide is missing — the real-value read has no black-box write to
|
|
6794
|
+
// observe, so tests call these directly.
|
|
6795
|
+
// Exported for tests (PROPOSAL-090): the canonical-region substitution-free
|
|
6796
|
+
// guard must be answer-independent — see placeholderTokens.
|
|
6797
|
+
placeholderTokens,
|
|
6798
|
+
inferTechStackSummary,
|
|
6799
|
+
inferMigrationContext,
|
|
6800
|
+
// Exported for tests (PROPOSAL-084): the id set is the contract between a
|
|
6801
|
+
// shipped `[uncertain]` line and the explainer page it sends the reader to.
|
|
6802
|
+
// Derived from the detector registry rather than retyped, so a new detector
|
|
6803
|
+
// cannot be added without the docs guard noticing it.
|
|
6804
|
+
DOCTOR_UNCERTAINTY_IDS,
|
|
6805
|
+
// Exported for tests (PROPOSAL-091): the route-(B) gates only take their
|
|
6806
|
+
// multi-candidate path when the edition is uninferable, and a fixture cannot
|
|
6807
|
+
// observe that through doctor's output — an unknown edition and a known one
|
|
6808
|
+
// reach the same finding, only the wording differs. The first version of that
|
|
6809
|
+
// fixture removed one of the three structural signals and stayed on the
|
|
6810
|
+
// single-candidate path while claiming to test the other, so the test asserts
|
|
6811
|
+
// the precondition directly instead of assuming it.
|
|
6812
|
+
inferProjectBundleEdition
|
|
3337
6813
|
};
|