create-sdd-project 0.17.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/adapt-agents.js +121 -29
- package/lib/diff-generator.js +7 -1
- package/lib/doctor.js +18 -0
- package/lib/init-generator.js +6 -1
- package/lib/meta.js +56 -3
- package/lib/scanner.js +127 -6
- package/lib/upgrade-generator.js +299 -73
- package/package.json +1 -1
package/lib/adapt-agents.js
CHANGED
|
@@ -65,6 +65,99 @@ const AGENT_ADAPTATION_RULES = {
|
|
|
65
65
|
},
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* v0.17.1: project-type-specific pruning rules for workflow-core files
|
|
70
|
+
* (SKILL.md + ticket-template.md). Keys are POSIX suffixes relative to
|
|
71
|
+
* the tool dir (e.g. `skills/development-workflow/SKILL.md`) so the same
|
|
72
|
+
* rules apply to both `.claude/` and `.gemini/` trees.
|
|
73
|
+
*
|
|
74
|
+
* These rules are the pure/in-memory equivalent of the inline block in
|
|
75
|
+
* `adaptAgentContentForProjectType`. Exposed here so upgrade-generator.js
|
|
76
|
+
* can build an accurate "what init would have produced" comparison target
|
|
77
|
+
* for smart-diff fallback paths — previously this was masked by
|
|
78
|
+
* unconditional `filesToAdapt.add` calls (pre-v0.17.1) that re-applied
|
|
79
|
+
* stack rules to restored user content, violating Codex M1 (Gemini round-3
|
|
80
|
+
* finding 1). Source of truth now lives here; disk-writing code below
|
|
81
|
+
* calls these same tables.
|
|
82
|
+
*/
|
|
83
|
+
const WORKFLOW_CORE_PROJECT_TYPE_RULES = {
|
|
84
|
+
backend: {
|
|
85
|
+
'skills/development-workflow/SKILL.md': [
|
|
86
|
+
[/,? `ui-components\.md`\)/, ')'],
|
|
87
|
+
[/- UI components → `docs\/specs\/ui-components\.md` \(MANDATORY\)\n/, ''],
|
|
88
|
+
[/\d+\. \*\*Design Review \(optional\):\*\*[^\n]*\n/, ''],
|
|
89
|
+
],
|
|
90
|
+
'skills/development-workflow/references/ticket-template.md': [
|
|
91
|
+
[/### UI Changes \(if applicable\)\n\n\[Components to add\/modify\. Reference `docs\/specs\/ui-components\.md`\.\]\n\n/, ''],
|
|
92
|
+
[' / `ui-components.md`', ''],
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
frontend: {
|
|
96
|
+
'skills/development-workflow/SKILL.md': [
|
|
97
|
+
[/`api-spec\.yaml`,? /, ''],
|
|
98
|
+
[/- API endpoints → `docs\/specs\/api-spec\.yaml` \(MANDATORY\)\n/, ''],
|
|
99
|
+
],
|
|
100
|
+
'skills/development-workflow/references/ticket-template.md': [
|
|
101
|
+
[/### API Changes \(if applicable\)\n\n\[Endpoints to add\/modify\. Reference[^\]]*\]\n\n/, ''],
|
|
102
|
+
['`api-spec.yaml` / ', ''],
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const BASE_STANDARDS_PROJECT_TYPE_RULES = {
|
|
108
|
+
backend: [
|
|
109
|
+
[/\| `ui-ux-designer` \|[^\n]*\n/, ''],
|
|
110
|
+
],
|
|
111
|
+
// frontend: no extra rules (base-standards template has no frontend-only refs to strip)
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function applyProjectTypeRules(content, rules) {
|
|
115
|
+
let result = content;
|
|
116
|
+
for (const [search, replace] of rules) {
|
|
117
|
+
if (search instanceof RegExp) {
|
|
118
|
+
result = result.replace(search, replace);
|
|
119
|
+
} else {
|
|
120
|
+
result = result.split(search).join(replace);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* v0.17.1 pure helper — apply project-type rules to a workflow-core file
|
|
128
|
+
* content. Returns content unchanged if projectType is fullstack or if
|
|
129
|
+
* posixPath doesn't match a known workflow-core file.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} content - Raw or stack-adapted content
|
|
132
|
+
* @param {string} posixPath - Full POSIX path (e.g. '.claude/skills/development-workflow/SKILL.md')
|
|
133
|
+
* @param {string} projectType - 'fullstack' | 'backend' | 'frontend'
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
function adaptWorkflowCoreContentForProjectType(content, posixPath, projectType) {
|
|
137
|
+
if (projectType === 'fullstack') return content;
|
|
138
|
+
const rulesForType = WORKFLOW_CORE_PROJECT_TYPE_RULES[projectType];
|
|
139
|
+
if (!rulesForType) return content;
|
|
140
|
+
|
|
141
|
+
// Strip the tool prefix (.claude/ or .gemini/) to match the rule key.
|
|
142
|
+
const match = posixPath.match(/^\.(?:claude|gemini)\/(.+)$/);
|
|
143
|
+
if (!match) return content;
|
|
144
|
+
const rules = rulesForType[match[1]];
|
|
145
|
+
if (!rules) return content;
|
|
146
|
+
|
|
147
|
+
return applyProjectTypeRules(content, rules);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* v0.17.1 pure helper — apply project-type rules to base-standards.mdc
|
|
152
|
+
* content. Called AFTER `adaptBaseStandards` to produce the full init
|
|
153
|
+
* equivalent for smart-diff comparison.
|
|
154
|
+
*/
|
|
155
|
+
function adaptBaseStandardsContentForProjectType(content, projectType) {
|
|
156
|
+
const rules = BASE_STANDARDS_PROJECT_TYPE_RULES[projectType];
|
|
157
|
+
if (!rules) return content;
|
|
158
|
+
return applyProjectTypeRules(content, rules);
|
|
159
|
+
}
|
|
160
|
+
|
|
68
161
|
/**
|
|
69
162
|
* Pure function — apply single-stack adaptation rules to an agent file's content.
|
|
70
163
|
*
|
|
@@ -130,43 +223,38 @@ function adaptAgentContentForProjectType(dest, config, replaceInFileFn) {
|
|
|
130
223
|
}
|
|
131
224
|
|
|
132
225
|
// --- Skills and templates: remove frontend/backend-specific references ---
|
|
133
|
-
//
|
|
226
|
+
// v0.17.1: SKILL.md + ticket-template.md rules now come from the
|
|
227
|
+
// WORKFLOW_CORE_PROJECT_TYPE_RULES table above so upgrade-generator.js
|
|
228
|
+
// can apply the same rules in-memory (smart-diff fallback comparison).
|
|
229
|
+
// pr-template.md + AGENTS.md + base-standards.mdc remain inline because
|
|
230
|
+
// they're not workflow-core files (pr-template is v0.17.2 scope).
|
|
231
|
+
const wfRules = WORKFLOW_CORE_PROJECT_TYPE_RULES[config.projectType];
|
|
232
|
+
if (wfRules) {
|
|
233
|
+
for (const dir of toolDirs) {
|
|
234
|
+
for (const [suffix, rules] of Object.entries(wfRules)) {
|
|
235
|
+
replaceInFileFn(path.join(dest, dir, ...suffix.split('/')), rules);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
134
240
|
if (config.projectType === 'backend') {
|
|
241
|
+
// AGENTS.md: remove ui-ux-designer from hook description
|
|
242
|
+
replaceInFileFn(path.join(dest, 'AGENTS.md'), [
|
|
243
|
+
[', `ui-ux-designer`', ''],
|
|
244
|
+
]);
|
|
135
245
|
for (const dir of toolDirs) {
|
|
136
|
-
//
|
|
137
|
-
replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'SKILL.md'), [
|
|
138
|
-
[/,? `ui-components\.md`\)/, ')'],
|
|
139
|
-
[/- UI components → `docs\/specs\/ui-components\.md` \(MANDATORY\)\n/, ''],
|
|
140
|
-
[/\d+\. \*\*Design Review \(optional\):\*\*[^\n]*\n/, ''],
|
|
141
|
-
]);
|
|
142
|
-
// AGENTS.md: remove ui-ux-designer from hook description
|
|
143
|
-
replaceInFileFn(path.join(dest, 'AGENTS.md'), [
|
|
144
|
-
[', `ui-ux-designer`', ''],
|
|
145
|
-
]);
|
|
146
|
-
// ticket-template: remove UI Changes section, ui-components from checklists
|
|
147
|
-
replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'references', 'ticket-template.md'), [
|
|
148
|
-
[/### UI Changes \(if applicable\)\n\n\[Components to add\/modify\. Reference `docs\/specs\/ui-components\.md`\.\]\n\n/, ''],
|
|
149
|
-
[' / `ui-components.md`', ''],
|
|
150
|
-
]);
|
|
151
|
-
// pr-template: remove ui-components from checklist
|
|
246
|
+
// pr-template: remove ui-components from checklist (v0.17.2 scope)
|
|
152
247
|
replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'references', 'pr-template.md'), [
|
|
153
248
|
[' / ui-components.md', ''],
|
|
154
249
|
]);
|
|
155
250
|
}
|
|
156
|
-
//
|
|
157
|
-
replaceInFileFn(
|
|
158
|
-
|
|
159
|
-
|
|
251
|
+
// base-standards.mdc: remove ui-ux-designer table row (shared table above)
|
|
252
|
+
replaceInFileFn(
|
|
253
|
+
path.join(dest, 'ai-specs', 'specs', 'base-standards.mdc'),
|
|
254
|
+
BASE_STANDARDS_PROJECT_TYPE_RULES.backend
|
|
255
|
+
);
|
|
160
256
|
} else if (config.projectType === 'frontend') {
|
|
161
257
|
for (const dir of toolDirs) {
|
|
162
|
-
replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'SKILL.md'), [
|
|
163
|
-
[/`api-spec\.yaml`,? /, ''],
|
|
164
|
-
[/- API endpoints → `docs\/specs\/api-spec\.yaml` \(MANDATORY\)\n/, ''],
|
|
165
|
-
]);
|
|
166
|
-
replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'references', 'ticket-template.md'), [
|
|
167
|
-
[/### API Changes \(if applicable\)\n\n\[Endpoints to add\/modify\. Reference[^\]]*\]\n\n/, ''],
|
|
168
|
-
['`api-spec.yaml` / ', ''],
|
|
169
|
-
]);
|
|
170
258
|
replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'references', 'pr-template.md'), [
|
|
171
259
|
['api-spec.yaml / ', ''],
|
|
172
260
|
]);
|
|
@@ -177,5 +265,9 @@ function adaptAgentContentForProjectType(dest, config, replaceInFileFn) {
|
|
|
177
265
|
module.exports = {
|
|
178
266
|
adaptAgentContentForProjectType,
|
|
179
267
|
adaptAgentContentString,
|
|
268
|
+
adaptWorkflowCoreContentForProjectType,
|
|
269
|
+
adaptBaseStandardsContentForProjectType,
|
|
180
270
|
AGENT_ADAPTATION_RULES,
|
|
271
|
+
WORKFLOW_CORE_PROJECT_TYPE_RULES,
|
|
272
|
+
BASE_STANDARDS_PROJECT_TYPE_RULES,
|
|
181
273
|
};
|
package/lib/diff-generator.js
CHANGED
|
@@ -13,11 +13,17 @@ const {
|
|
|
13
13
|
adaptFrontendStandards,
|
|
14
14
|
} = require('./init-generator');
|
|
15
15
|
const {
|
|
16
|
-
isStandardModified,
|
|
17
16
|
getPackageVersion,
|
|
18
17
|
} = require('./upgrade-generator');
|
|
18
|
+
const { normalizedContentEquals } = require('./meta');
|
|
19
19
|
const { formatScanSummary } = require('./init-wizard');
|
|
20
20
|
|
|
21
|
+
// v0.17.1: isStandardModified was removed. Replace callers with inverted
|
|
22
|
+
// normalizedContentEquals — "modified" means "not equal after normalization".
|
|
23
|
+
function isStandardModified(existing, fresh) {
|
|
24
|
+
return !normalizedContentEquals(existing, fresh);
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
const templateDir = path.join(__dirname, '..', 'template');
|
|
22
28
|
|
|
23
29
|
/**
|
package/lib/doctor.js
CHANGED
|
@@ -979,6 +979,24 @@ function checkAgentsMdStandardsRefs(cwd) {
|
|
|
979
979
|
);
|
|
980
980
|
}
|
|
981
981
|
|
|
982
|
+
// v0.17.1 observability (Gemini Q10): warn on sparse Backend/Frontend patterns
|
|
983
|
+
// — exactly 1 entry — suggesting scanner detection missed framework or ORM.
|
|
984
|
+
// Permissive: non-failing, informational. Two+ entries are assumed OK because
|
|
985
|
+
// projects legitimately vary (ORM-only backends, component-less frontends).
|
|
986
|
+
const sparseRe = /(Backend|Frontend) patterns \(([^)]+)\)/g;
|
|
987
|
+
let sparseMatch;
|
|
988
|
+
while ((sparseMatch = sparseRe.exec(content)) !== null) {
|
|
989
|
+
const rawEntries = sparseMatch[2]
|
|
990
|
+
.split(',')
|
|
991
|
+
.map((s) => s.trim())
|
|
992
|
+
.filter((s) => s.length > 0);
|
|
993
|
+
if (rawEntries.length === 1) {
|
|
994
|
+
issues.push(
|
|
995
|
+
`${sparseMatch[1]} patterns has only 1 entry (${rawEntries[0]}) — scanner detection may be incomplete; run \`npx create-sdd-project --upgrade\` after installing your stack deps to re-detect`
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
982
1000
|
// Detect unsubstituted placeholders that look like "[Framework, runtime, version]".
|
|
983
1001
|
// Template placeholders are distinctive: (a) they contain at least one
|
|
984
1002
|
// comma-separated descriptor or the literal word "your", (b) they are NOT
|
package/lib/init-generator.js
CHANGED
|
@@ -576,10 +576,15 @@ function adaptFrontendStandards(template, scan) {
|
|
|
576
576
|
);
|
|
577
577
|
|
|
578
578
|
// Update Project Structure
|
|
579
|
+
// v0.17.1: the regex consumes the optional trailing TODO line so idempotent
|
|
580
|
+
// reapplication doesn't duplicate the marker. Before: on second call the
|
|
581
|
+
// regex only matched up to the closing ``` and the replacement re-inserted
|
|
582
|
+
// the TODO, producing two copies. After: the (\n\n<!-- TODO: ... -->)? group
|
|
583
|
+
// is included in the match so the replacement overwrites it.
|
|
579
584
|
const rootDirs = scan.rootDirs.filter((d) => !['docs/', 'ai-specs/', 'node_modules/'].includes(d));
|
|
580
585
|
const tree = rootDirs.map((d) => `├── ${d.replace(/\/$/, '/')}`).join('\n');
|
|
581
586
|
content = content.replace(
|
|
582
|
-
/## Project Structure\n\n```\n[\s\S]
|
|
587
|
+
/## Project Structure\n\n```\n[\s\S]*?```(\n\n<!-- TODO: Expand the structure above[^\n]*-->)?/,
|
|
583
588
|
`## Project Structure\n\n\`\`\`\nproject/\n${tree}\n\`\`\`\n\n<!-- TODO: Expand the structure above with your key subdirectories. -->`
|
|
584
589
|
);
|
|
585
590
|
|
package/lib/meta.js
CHANGED
|
@@ -64,6 +64,32 @@ function normalizeForCompare(text) {
|
|
|
64
64
|
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* v0.17.1: file-agnostic content equality helper. Returns true when two
|
|
69
|
+
* strings are equal after CR/CRLF normalization. Replaces the v0.17.0
|
|
70
|
+
* `isStandardModified` function, which was standards-specific in name but
|
|
71
|
+
* identical in logic after the Gemini M2 normalization fix.
|
|
72
|
+
*
|
|
73
|
+
* Rename rationale (round-2 review consolidated): Codex round-1 Q9 wanted
|
|
74
|
+
* `isStandardModified` deleted; Gemini round-1 Q9 wanted a named helper
|
|
75
|
+
* for fallback-path readability; Codex round-2 Q11 pointed out that the
|
|
76
|
+
* kept-name-as-wrapper compromise was vestigial because the name still
|
|
77
|
+
* implied standards-specific policy. This rename makes the helper's
|
|
78
|
+
* file-agnostic nature explicit and its location (next to its dependency
|
|
79
|
+
* `normalizeForCompare`) structural.
|
|
80
|
+
*
|
|
81
|
+
* Scope of normalization (important, often misstated in earlier plan
|
|
82
|
+
* drafts): this helper ONLY normalizes CR/CRLF line endings. It does NOT
|
|
83
|
+
* strip trailing whitespace per line nor leading/trailing blank lines —
|
|
84
|
+
* that would destroy markdown hard-breaks (two trailing spaces render as
|
|
85
|
+
* `<br>`) and silently wipe user customizations that only touched
|
|
86
|
+
* whitespace. The conservative behavior is intentional; the alternative
|
|
87
|
+
* is strictly worse.
|
|
88
|
+
*/
|
|
89
|
+
function normalizedContentEquals(a, b) {
|
|
90
|
+
return normalizeForCompare(a) === normalizeForCompare(b);
|
|
91
|
+
}
|
|
92
|
+
|
|
67
93
|
/**
|
|
68
94
|
* Compute the content-addressable hash of a string.
|
|
69
95
|
*
|
|
@@ -203,9 +229,16 @@ function writeMeta(dest, hashes) {
|
|
|
203
229
|
* if the file exists on disk.
|
|
204
230
|
*
|
|
205
231
|
* v0.17.0 scope: template agents (.claude/agents/*, .gemini/agents/*)
|
|
206
|
-
* + AGENTS.md.
|
|
207
|
-
*
|
|
208
|
-
*
|
|
232
|
+
* + AGENTS.md.
|
|
233
|
+
*
|
|
234
|
+
* v0.17.1 scope extension (Codex M1 option 2 deferred from v0.17.0):
|
|
235
|
+
* - 4 standards files (ai-specs/specs/*.mdc) — always tracked
|
|
236
|
+
* - 6 workflow-core files (development-workflow SKILL.md + ticket-template.md
|
|
237
|
+
* + merge-checklist.md, × 2 tools) — filtered by aiTools
|
|
238
|
+
*
|
|
239
|
+
* Out of scope for v0.17.1 (deferred to v0.17.2): bug-workflow/SKILL.md,
|
|
240
|
+
* health-check/SKILL.md, pm-orchestrator/SKILL.md, project-memory/SKILL.md,
|
|
241
|
+
* and all references/ files except the 3 development-workflow ones above.
|
|
209
242
|
*/
|
|
210
243
|
function expectedSmartDiffTrackedPaths(aiTools, projectType) {
|
|
211
244
|
const paths = new Set();
|
|
@@ -228,6 +261,25 @@ function expectedSmartDiffTrackedPaths(aiTools, projectType) {
|
|
|
228
261
|
|
|
229
262
|
paths.add('AGENTS.md');
|
|
230
263
|
|
|
264
|
+
// v0.17.1: standards — always tracked (independent of aiTools/projectType).
|
|
265
|
+
// Project-type filtering (backend-only skips frontend-standards.mdc) is
|
|
266
|
+
// enforced by the existing install/upgrade pipeline that only writes the
|
|
267
|
+
// relevant standards for the project type; their hash entries are simply
|
|
268
|
+
// absent for the non-applicable side.
|
|
269
|
+
paths.add('ai-specs/specs/base-standards.mdc');
|
|
270
|
+
if (projectType !== 'frontend') paths.add('ai-specs/specs/backend-standards.mdc');
|
|
271
|
+
if (projectType !== 'backend') paths.add('ai-specs/specs/frontend-standards.mdc');
|
|
272
|
+
paths.add('ai-specs/specs/documentation-standards.mdc');
|
|
273
|
+
|
|
274
|
+
// v0.17.1: development-workflow skill core files — filtered by aiTools.
|
|
275
|
+
// bug-workflow, health-check, pm-orchestrator, project-memory are OUT OF
|
|
276
|
+
// SCOPE for v0.17.1 (deferred to v0.17.2).
|
|
277
|
+
for (const dir of toolDirs) {
|
|
278
|
+
paths.add(`${dir}/skills/development-workflow/SKILL.md`);
|
|
279
|
+
paths.add(`${dir}/skills/development-workflow/references/ticket-template.md`);
|
|
280
|
+
paths.add(`${dir}/skills/development-workflow/references/merge-checklist.md`);
|
|
281
|
+
}
|
|
282
|
+
|
|
231
283
|
return paths;
|
|
232
284
|
}
|
|
233
285
|
|
|
@@ -283,6 +335,7 @@ module.exports = {
|
|
|
283
335
|
hashFileOnDisk,
|
|
284
336
|
toPosix,
|
|
285
337
|
normalizeForCompare,
|
|
338
|
+
normalizedContentEquals,
|
|
286
339
|
readMeta,
|
|
287
340
|
writeMeta,
|
|
288
341
|
expectedSmartDiffTrackedPaths,
|
package/lib/scanner.js
CHANGED
|
@@ -7,17 +7,56 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.n
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Scan an existing project directory and return detected configuration.
|
|
10
|
+
*
|
|
11
|
+
* v0.17.1: monorepo-aware. If the root `package.json` does not yield a
|
|
12
|
+
* backend/frontend detection AND the project is a monorepo with
|
|
13
|
+
* `package.json#workspaces`, enumerate workspace `package.json` files in
|
|
14
|
+
* declaration order (pattern outer, lexical inner, deduped by normalized
|
|
15
|
+
* path) and run `detectBackend` / `detectFrontend` per workspace. The
|
|
16
|
+
* FIRST workspace returning `detected: true` wins and its result is merged
|
|
17
|
+
* into `result.backend` / `result.frontend` with a `workspaceSource` field
|
|
18
|
+
* recording the detected workspace's relative path (for diagnostics).
|
|
19
|
+
*
|
|
20
|
+
* Scanner additive invariant (v0.17.1): for single-package projects, or
|
|
21
|
+
* monorepos where root detection already succeeded, the workspace
|
|
22
|
+
* enumeration never fires and the result is byte-identical to v0.17.0.
|
|
10
23
|
*/
|
|
11
24
|
function scan(projectDir) {
|
|
12
25
|
const pkg = readPackageJson(projectDir);
|
|
13
26
|
|
|
14
|
-
const
|
|
27
|
+
const backend = detectBackend(projectDir, pkg);
|
|
28
|
+
const frontend = detectFrontend(projectDir, pkg);
|
|
29
|
+
const isMonorepo = detectMonorepo(projectDir, pkg);
|
|
30
|
+
|
|
31
|
+
// v0.17.1 monorepo fallback
|
|
32
|
+
if (isMonorepo && (!backend.detected || !frontend.detected)) {
|
|
33
|
+
const workspaces = enumerateWorkspaces(projectDir, pkg);
|
|
34
|
+
for (const wsRel of workspaces) {
|
|
35
|
+
const wsAbs = path.join(projectDir, ...wsRel.split('/'));
|
|
36
|
+
const wsPkg = readPackageJson(wsAbs);
|
|
37
|
+
if (!backend.detected) {
|
|
38
|
+
const wsBackend = detectBackend(wsAbs, wsPkg);
|
|
39
|
+
if (wsBackend.detected) {
|
|
40
|
+
Object.assign(backend, wsBackend, { workspaceSource: wsRel });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!frontend.detected) {
|
|
44
|
+
const wsFrontend = detectFrontend(wsAbs, wsPkg);
|
|
45
|
+
if (wsFrontend.detected) {
|
|
46
|
+
Object.assign(frontend, wsFrontend, { workspaceSource: wsRel });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (backend.detected && frontend.detected) break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
15
54
|
projectName: pkg.name || path.basename(projectDir),
|
|
16
55
|
description: pkg.description || '',
|
|
17
56
|
language: detectLanguage(projectDir),
|
|
18
|
-
backend
|
|
19
|
-
frontend
|
|
20
|
-
isMonorepo
|
|
57
|
+
backend,
|
|
58
|
+
frontend,
|
|
59
|
+
isMonorepo,
|
|
21
60
|
rootDirs: listRootDirs(projectDir),
|
|
22
61
|
srcStructure: detectArchitecture(projectDir, pkg),
|
|
23
62
|
tests: detectTests(projectDir, pkg),
|
|
@@ -25,8 +64,90 @@ function scan(projectDir) {
|
|
|
25
64
|
gitBranch: detectGitBranch(projectDir),
|
|
26
65
|
hasGit: fs.existsSync(path.join(projectDir, '.git')),
|
|
27
66
|
};
|
|
67
|
+
}
|
|
28
68
|
|
|
29
|
-
|
|
69
|
+
/**
|
|
70
|
+
* v0.17.1: enumerate workspace paths declared in `pkg.workspaces`.
|
|
71
|
+
*
|
|
72
|
+
* Supports:
|
|
73
|
+
* - Array form: `"workspaces": ["packages/*", "apps/*"]`
|
|
74
|
+
* - Object form: `"workspaces": { "packages": ["packages/*"] }`
|
|
75
|
+
* - Literal paths: `"packages/api"` (no glob)
|
|
76
|
+
* - Single-wildcard patterns: `"packages/*"` (expand immediate subdirs)
|
|
77
|
+
*
|
|
78
|
+
* Does NOT support: `**` recursive patterns, `!exclude` negation, or
|
|
79
|
+
* `pnpm-workspace.yaml` — all deferred to v0.17.2.
|
|
80
|
+
*
|
|
81
|
+
* Returns a deterministic, deduplicated array of POSIX-style relative
|
|
82
|
+
* workspace paths. Ordering: outer = declaration order of patterns; inner
|
|
83
|
+
* = lexical Unicode codepoint sort of expanded subdirs; dedupe = first
|
|
84
|
+
* occurrence wins after flattening (Codex + Gemini round-2 Q7).
|
|
85
|
+
*/
|
|
86
|
+
function enumerateWorkspaces(dir, pkg) {
|
|
87
|
+
let patterns = [];
|
|
88
|
+
if (Array.isArray(pkg.workspaces)) {
|
|
89
|
+
patterns = pkg.workspaces;
|
|
90
|
+
} else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) {
|
|
91
|
+
patterns = pkg.workspaces.packages;
|
|
92
|
+
}
|
|
93
|
+
if (patterns.length === 0) return [];
|
|
94
|
+
|
|
95
|
+
const flat = [];
|
|
96
|
+
for (const pattern of patterns) {
|
|
97
|
+
flat.push(...expandWorkspacePattern(dir, pattern));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const deduped = [];
|
|
102
|
+
for (const wsPath of flat) {
|
|
103
|
+
const normalized = wsPath.replace(/\\/g, '/').replace(/\/$/, '');
|
|
104
|
+
if (seen.has(normalized)) continue;
|
|
105
|
+
seen.add(normalized);
|
|
106
|
+
deduped.push(normalized);
|
|
107
|
+
}
|
|
108
|
+
return deduped;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function expandWorkspacePattern(dir, pattern) {
|
|
112
|
+
// npm/yarn workspace semantics: a workspace is a DIRECTORY CONTAINING
|
|
113
|
+
// package.json. Directories without package.json are not workspaces,
|
|
114
|
+
// even if they live under a matched glob (Codex round-3 finding 1).
|
|
115
|
+
// This prevents the scanner from wasting work on stray folders
|
|
116
|
+
// (docs/, shared assets, build outputs) and keeps first-match-wins
|
|
117
|
+
// deterministic against only declared workspace packages.
|
|
118
|
+
const hasPkgJson = (absDir) => {
|
|
119
|
+
try {
|
|
120
|
+
return fs.statSync(path.join(absDir, 'package.json')).isFile();
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (!pattern.includes('*')) {
|
|
127
|
+
const absPath = path.join(dir, pattern);
|
|
128
|
+
try {
|
|
129
|
+
if (fs.statSync(absPath).isDirectory() && hasPkgJson(absPath)) {
|
|
130
|
+
return [pattern.replace(/\\/g, '/')];
|
|
131
|
+
}
|
|
132
|
+
} catch { /* not found or not a dir */ }
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
// Only support trailing single-wildcard: `foo/*` or `foo/bar/*`
|
|
136
|
+
const match = pattern.match(/^(.+)\/\*$/);
|
|
137
|
+
if (!match) return [];
|
|
138
|
+
const baseDir = match[1];
|
|
139
|
+
const baseDirAbs = path.join(dir, baseDir);
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = fs.readdirSync(baseDirAbs, { withFileTypes: true });
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
return entries
|
|
147
|
+
.filter((e) => e.isDirectory() && hasPkgJson(path.join(baseDirAbs, e.name)))
|
|
148
|
+
.map((e) => e.name)
|
|
149
|
+
.sort()
|
|
150
|
+
.map((name) => `${baseDir}/${name}`);
|
|
30
151
|
}
|
|
31
152
|
|
|
32
153
|
// --- Helpers ---
|
|
@@ -541,4 +662,4 @@ function detectGitBranch(dir) {
|
|
|
541
662
|
return 'main';
|
|
542
663
|
}
|
|
543
664
|
|
|
544
|
-
module.exports = { scan };
|
|
665
|
+
module.exports = { scan, enumerateWorkspaces, expandWorkspacePattern };
|
package/lib/upgrade-generator.js
CHANGED
|
@@ -12,6 +12,8 @@ const {
|
|
|
12
12
|
const {
|
|
13
13
|
adaptAgentContentForProjectType,
|
|
14
14
|
adaptAgentContentString,
|
|
15
|
+
adaptWorkflowCoreContentForProjectType,
|
|
16
|
+
adaptBaseStandardsContentForProjectType,
|
|
15
17
|
} = require('./adapt-agents');
|
|
16
18
|
const {
|
|
17
19
|
adaptBaseStandards,
|
|
@@ -25,6 +27,7 @@ const {
|
|
|
25
27
|
regexReplaceInFile,
|
|
26
28
|
} = require('./init-generator');
|
|
27
29
|
// v0.17.0: hash-based smart-diff + shared stack adaptations
|
|
30
|
+
// v0.17.1: + normalizedContentEquals (replaces isStandardModified)
|
|
28
31
|
const {
|
|
29
32
|
readMeta,
|
|
30
33
|
writeMeta,
|
|
@@ -34,6 +37,7 @@ const {
|
|
|
34
37
|
pruneExpectedAbsent,
|
|
35
38
|
expectedSmartDiffTrackedPaths,
|
|
36
39
|
normalizeForCompare: metaNormalizeForCompare,
|
|
40
|
+
normalizedContentEquals,
|
|
37
41
|
} = require('./meta');
|
|
38
42
|
const {
|
|
39
43
|
applyStackAdaptations,
|
|
@@ -223,14 +227,6 @@ function collectCustomCommands(dest) {
|
|
|
223
227
|
return customs;
|
|
224
228
|
}
|
|
225
229
|
|
|
226
|
-
/**
|
|
227
|
-
* Check if a standard file has been modified by the user.
|
|
228
|
-
* Compares existing file against freshly generated version.
|
|
229
|
-
*/
|
|
230
|
-
function isStandardModified(existingContent, freshContent) {
|
|
231
|
-
return existingContent.trim() !== freshContent.trim();
|
|
232
|
-
}
|
|
233
|
-
|
|
234
230
|
/**
|
|
235
231
|
* Build the upgrade summary for display.
|
|
236
232
|
*/
|
|
@@ -308,6 +304,38 @@ function generateUpgrade(config) {
|
|
|
308
304
|
const newHashes = { ...(meta?.hashes ?? {}) };
|
|
309
305
|
const filesToAdapt = new Set();
|
|
310
306
|
|
|
307
|
+
// v0.17.1: before the skills/ wholesale delete-and-copy, save the content
|
|
308
|
+
// of the 6 workflow-core files so we can restore them if their hash tells
|
|
309
|
+
// us they were customized. Map keyed by absolute path, value = string or
|
|
310
|
+
// null (null = file didn't exist before upgrade).
|
|
311
|
+
const workflowCoreBackup = new Map();
|
|
312
|
+
const workflowCorePosixPaths = [];
|
|
313
|
+
{
|
|
314
|
+
const dirsForBackup = [];
|
|
315
|
+
if (aiTools !== 'gemini') dirsForBackup.push('.claude');
|
|
316
|
+
if (aiTools !== 'claude') dirsForBackup.push('.gemini');
|
|
317
|
+
for (const dir of dirsForBackup) {
|
|
318
|
+
for (const relSub of [
|
|
319
|
+
'skills/development-workflow/SKILL.md',
|
|
320
|
+
'skills/development-workflow/references/ticket-template.md',
|
|
321
|
+
'skills/development-workflow/references/merge-checklist.md',
|
|
322
|
+
]) {
|
|
323
|
+
const posix = `${dir}/${relSub}`;
|
|
324
|
+
const abs = path.join(dest, ...posix.split('/'));
|
|
325
|
+
workflowCorePosixPaths.push({ posix, abs });
|
|
326
|
+
if (fs.existsSync(abs)) {
|
|
327
|
+
try {
|
|
328
|
+
workflowCoreBackup.set(abs, fs.readFileSync(abs, 'utf8'));
|
|
329
|
+
} catch {
|
|
330
|
+
workflowCoreBackup.set(abs, null);
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
workflowCoreBackup.set(abs, null);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
311
339
|
console.log(`\nUpgrading SDD DevFlow in ${config.projectName}...\n`);
|
|
312
340
|
console.log(` Backup directory: .sdd-backup/${backupTimestamp}/\n`);
|
|
313
341
|
|
|
@@ -567,67 +595,266 @@ function generateUpgrade(config) {
|
|
|
567
595
|
preserved++;
|
|
568
596
|
}
|
|
569
597
|
|
|
570
|
-
// ---
|
|
571
|
-
|
|
598
|
+
// --- c2) v0.17.1: workflow-core smart-diff protection ---
|
|
599
|
+
//
|
|
600
|
+
// The 6 development-workflow files (SKILL.md + ticket-template.md +
|
|
601
|
+
// merge-checklist.md, × 2 tools) were just wholesale-copied by the
|
|
602
|
+
// skills/ delete-and-replace at step (b). Without this block, user
|
|
603
|
+
// customizations to the core workflow files (ticket templates, merge
|
|
604
|
+
// checklists, SKILL.md definitions) would be silently lost every
|
|
605
|
+
// upgrade. Check each against its pre-upgrade backup (captured before
|
|
606
|
+
// step (b)) via the hash decision tree; restore the backup if the user
|
|
607
|
+
// had customized it, otherwise leave the template version in place.
|
|
608
|
+
for (const { posix, abs } of workflowCorePosixPaths) {
|
|
609
|
+
const backup = workflowCoreBackup.get(abs);
|
|
610
|
+
const relativePath = path.relative(dest, abs);
|
|
611
|
+
|
|
612
|
+
if (backup === null || backup === undefined) {
|
|
613
|
+
// File did not exist pre-upgrade (first install or user deleted it)
|
|
614
|
+
// → leave the freshly-copied template version in place.
|
|
615
|
+
if (fs.existsSync(abs)) {
|
|
616
|
+
filesToAdapt.add(posix);
|
|
617
|
+
replaced++;
|
|
618
|
+
}
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
572
621
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const template = fs.readFileSync(path.join(templateDir, 'ai-specs', 'specs', 'base-standards.mdc'), 'utf8');
|
|
578
|
-
const fresh = adaptBaseStandards(template, scan, config);
|
|
579
|
-
if (isStandardModified(existing, fresh)) {
|
|
580
|
-
standardsResults.push({ name: 'ai-specs/specs/base-standards.mdc', modified: true });
|
|
581
|
-
preserved++;
|
|
582
|
-
} else {
|
|
583
|
-
fs.writeFileSync(baseStdPath, fresh, 'utf8');
|
|
584
|
-
standardsResults.push({ name: 'ai-specs/specs/base-standards.mdc', modified: false });
|
|
622
|
+
if (config.forceTemplate) {
|
|
623
|
+
// --force-template: keep the fresh template, back up the old content.
|
|
624
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
625
|
+
filesToAdapt.add(posix);
|
|
585
626
|
replaced++;
|
|
627
|
+
continue;
|
|
586
628
|
}
|
|
587
|
-
}
|
|
588
629
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
630
|
+
const freshContent = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
|
|
631
|
+
if (freshContent === null) {
|
|
632
|
+
// Template no longer ships this file (should not happen in v0.17.1)
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const storedHash = meta && meta.hashes[posix];
|
|
637
|
+
const backupHash = computeHash(backup);
|
|
638
|
+
|
|
639
|
+
if (storedHash) {
|
|
640
|
+
// Case 2: hash-based path.
|
|
641
|
+
if (backupHash === storedHash) {
|
|
642
|
+
// Pristine → keep the fresh copy, back up the pre-upgrade version.
|
|
643
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
644
|
+
filesToAdapt.add(posix);
|
|
602
645
|
replaced++;
|
|
646
|
+
continue;
|
|
603
647
|
}
|
|
648
|
+
// Hash mismatch → customized → restore backup + write .new with
|
|
649
|
+
// adapted target so user can diff against canonical v0.17.1 output.
|
|
650
|
+
// v0.17.1 round-3: the .new backup must mirror what init would have
|
|
651
|
+
// produced (stack rules + project-type rules), so the user can diff
|
|
652
|
+
// apples-to-apples against their customized file.
|
|
653
|
+
const stackTarget = applyStackAdaptationsToContent(freshContent, posix, scan, config);
|
|
654
|
+
const adaptedTarget = adaptWorkflowCoreContentForProjectType(stackTarget, posix, projectType);
|
|
655
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
656
|
+
fs.writeFileSync(abs, backup, 'utf8');
|
|
657
|
+
const newBackupPath = path.join(
|
|
658
|
+
dest,
|
|
659
|
+
'.sdd-backup',
|
|
660
|
+
backupTimestamp,
|
|
661
|
+
`${relativePath}.new`
|
|
662
|
+
);
|
|
663
|
+
try {
|
|
664
|
+
fs.mkdirSync(path.dirname(newBackupPath), { recursive: true });
|
|
665
|
+
fs.writeFileSync(newBackupPath, adaptedTarget, 'utf8');
|
|
666
|
+
} catch (e) {
|
|
667
|
+
console.warn(` ⚠ Failed to write .new backup for ${relativePath}: ${e.code || e.message}`);
|
|
668
|
+
}
|
|
669
|
+
modifiedAgentsResults.push({ name: relativePath, modified: true });
|
|
670
|
+
preserved++;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Case 3: fallback path — no stored hash (pre-v0.17.1 project). Compare
|
|
675
|
+
// backup against the FULLY adapted template target (stack rules +
|
|
676
|
+
// project-type rules) OR the raw template content. Both are valid
|
|
677
|
+
// "pristine" states for a pre-v0.17.1 project:
|
|
678
|
+
// (a) adapted: --init ran applyStackAdaptations AND
|
|
679
|
+
// adaptAgentContentForProjectType at install time (full adapted)
|
|
680
|
+
// (b) raw: generator.js scaffold copied template without running adapters
|
|
681
|
+
// v0.17.1 round-3: BOTH stack rules and project-type rules must be
|
|
682
|
+
// applied to the comparison target. Previously only stack rules were
|
|
683
|
+
// applied, which caused false-positive preserve on single-stack projects
|
|
684
|
+
// (scenario 55 regression guard).
|
|
685
|
+
const stackTarget = applyStackAdaptationsToContent(freshContent, posix, scan, config);
|
|
686
|
+
const adaptedTarget = adaptWorkflowCoreContentForProjectType(stackTarget, posix, projectType);
|
|
687
|
+
if (
|
|
688
|
+
normalizedContentEquals(backup, adaptedTarget) ||
|
|
689
|
+
normalizedContentEquals(backup, freshContent)
|
|
690
|
+
) {
|
|
691
|
+
// Pristine per content compare → keep the fresh copy.
|
|
692
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
693
|
+
filesToAdapt.add(posix);
|
|
694
|
+
replaced++;
|
|
695
|
+
continue;
|
|
604
696
|
}
|
|
697
|
+
// Customized → restore backup + write .new with adapted target.
|
|
698
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
699
|
+
fs.writeFileSync(abs, backup, 'utf8');
|
|
700
|
+
const newBackupPath = path.join(
|
|
701
|
+
dest,
|
|
702
|
+
'.sdd-backup',
|
|
703
|
+
backupTimestamp,
|
|
704
|
+
`${relativePath}.new`
|
|
705
|
+
);
|
|
706
|
+
try {
|
|
707
|
+
fs.mkdirSync(path.dirname(newBackupPath), { recursive: true });
|
|
708
|
+
fs.writeFileSync(newBackupPath, adaptedTarget, 'utf8');
|
|
709
|
+
} catch (e) {
|
|
710
|
+
console.warn(` ⚠ Failed to write .new backup for ${relativePath}: ${e.code || e.message}`);
|
|
711
|
+
}
|
|
712
|
+
modifiedAgentsResults.push({ name: relativePath, modified: true });
|
|
713
|
+
preserved++;
|
|
605
714
|
}
|
|
606
715
|
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
716
|
+
// --- d) Handle standards (smart diff) ---
|
|
717
|
+
const standardsResults = [];
|
|
718
|
+
|
|
719
|
+
// v0.17.1: hash decision tree for all 4 standards.
|
|
720
|
+
//
|
|
721
|
+
// Each standard uses its own adapter (adaptBaseStandards, adaptBackendStandards,
|
|
722
|
+
// adaptFrontendStandards, or — for documentation-standards — the imperative
|
|
723
|
+
// branch in applyStackAdaptationsToContent). The decision tree is uniform:
|
|
724
|
+
// 1. Missing or --force-template → unconditional write + hash update
|
|
725
|
+
// 2. Stored hash match → pristine, replace + hash update
|
|
726
|
+
// 3. Stored hash mismatch → customized, preserve + .new backup, NO hash update
|
|
727
|
+
// 4. No stored hash → fallback compare against adapted target (normalizedContentEquals)
|
|
728
|
+
//
|
|
729
|
+
// Replaces v0.17.0's isStandardModified main-path compare (deleted in this commit).
|
|
730
|
+
// Standards are added to filesToAdapt when replaced, so their hashes get
|
|
731
|
+
// computed at the end of the upgrade via the filesToAdapt loop.
|
|
732
|
+
const standardsSpecs = [
|
|
733
|
+
{
|
|
734
|
+
posix: 'ai-specs/specs/base-standards.mdc',
|
|
735
|
+
relTemplate: ['ai-specs', 'specs', 'base-standards.mdc'],
|
|
736
|
+
// v0.17.1 round-3: init applies project-type rules via
|
|
737
|
+
// adaptAgentContentForProjectType AFTER adaptBaseStandards, so the
|
|
738
|
+
// comparison target must include both layers to avoid false-positive
|
|
739
|
+
// preserve on single-stack upgrades (scenario 55 regression guard).
|
|
740
|
+
adapter: (tpl) => adaptBaseStandardsContentForProjectType(
|
|
741
|
+
adaptBaseStandards(tpl, scan, config),
|
|
742
|
+
projectType
|
|
743
|
+
),
|
|
744
|
+
include: true,
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
posix: 'ai-specs/specs/backend-standards.mdc',
|
|
748
|
+
relTemplate: ['ai-specs', 'specs', 'backend-standards.mdc'],
|
|
749
|
+
adapter: (tpl) => adaptBackendStandards(tpl, scan),
|
|
750
|
+
include: projectType !== 'frontend',
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
posix: 'ai-specs/specs/frontend-standards.mdc',
|
|
754
|
+
relTemplate: ['ai-specs', 'specs', 'frontend-standards.mdc'],
|
|
755
|
+
adapter: (tpl) => adaptFrontendStandards(tpl, scan),
|
|
756
|
+
include: projectType !== 'backend',
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
posix: 'ai-specs/specs/documentation-standards.mdc',
|
|
760
|
+
relTemplate: ['ai-specs', 'specs', 'documentation-standards.mdc'],
|
|
761
|
+
// documentation-standards has no dedicated adapter; stack-adaptations.js
|
|
762
|
+
// handles project-type pruning via its imperative branch at the end of
|
|
763
|
+
// applyStackAdaptations. For the adapted target used in fallback
|
|
764
|
+
// content-compare here, we apply it in-memory via
|
|
765
|
+
// applyStackAdaptationsToContent (same helper, different invocation).
|
|
766
|
+
adapter: (tpl) => applyStackAdaptationsToContent(tpl, 'ai-specs/specs/documentation-standards.mdc', scan, config),
|
|
767
|
+
include: true,
|
|
768
|
+
},
|
|
769
|
+
];
|
|
770
|
+
|
|
771
|
+
for (const spec of standardsSpecs) {
|
|
772
|
+
if (!spec.include) continue;
|
|
773
|
+
const absPath = path.join(dest, ...spec.posix.split('/'));
|
|
774
|
+
const templatePath = path.join(templateDir, ...spec.relTemplate);
|
|
775
|
+
if (!fs.existsSync(templatePath)) continue;
|
|
776
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
777
|
+
const freshAdapted = spec.adapter(template);
|
|
778
|
+
const relativePath = path.relative(dest, absPath);
|
|
779
|
+
|
|
780
|
+
if (!fs.existsSync(absPath)) {
|
|
781
|
+
// Missing → unconditional write
|
|
782
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
783
|
+
fs.writeFileSync(absPath, freshAdapted, 'utf8');
|
|
784
|
+
filesToAdapt.add(spec.posix);
|
|
785
|
+
standardsResults.push({ name: spec.posix, modified: false });
|
|
786
|
+
replaced++;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (config.forceTemplate) {
|
|
791
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
792
|
+
fs.writeFileSync(absPath, freshAdapted, 'utf8');
|
|
793
|
+
filesToAdapt.add(spec.posix);
|
|
794
|
+
standardsResults.push({ name: spec.posix, modified: false });
|
|
795
|
+
replaced++;
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const existing = fs.readFileSync(absPath, 'utf8');
|
|
800
|
+
const storedHash = meta && meta.hashes[spec.posix];
|
|
801
|
+
|
|
802
|
+
const preserveStandard = () => {
|
|
803
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
804
|
+
const newBackupPath = path.join(dest, '.sdd-backup', backupTimestamp, `${relativePath}.new`);
|
|
805
|
+
try {
|
|
806
|
+
fs.mkdirSync(path.dirname(newBackupPath), { recursive: true });
|
|
807
|
+
fs.writeFileSync(newBackupPath, freshAdapted, 'utf8');
|
|
808
|
+
} catch (e) {
|
|
809
|
+
console.warn(` ⚠ Failed to write .new backup for ${relativePath}: ${e.code || e.message}`);
|
|
810
|
+
}
|
|
811
|
+
standardsResults.push({ name: spec.posix, modified: true });
|
|
812
|
+
preserved++;
|
|
813
|
+
// Codex M1 invariant: do NOT update newHashes[spec.posix] — the
|
|
814
|
+
// inherited hash (if any) persists untouched for preserved files.
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
if (storedHash) {
|
|
818
|
+
// Case 2: hash-based path.
|
|
819
|
+
const currentHash = computeHash(existing);
|
|
820
|
+
if (currentHash === storedHash) {
|
|
821
|
+
// Pristine → replace with adapted target.
|
|
822
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
823
|
+
fs.writeFileSync(absPath, freshAdapted, 'utf8');
|
|
824
|
+
filesToAdapt.add(spec.posix);
|
|
825
|
+
standardsResults.push({ name: spec.posix, modified: false });
|
|
620
826
|
replaced++;
|
|
827
|
+
continue;
|
|
621
828
|
}
|
|
829
|
+
// Hash mismatch → preserve.
|
|
830
|
+
preserveStandard();
|
|
831
|
+
continue;
|
|
622
832
|
}
|
|
623
|
-
}
|
|
624
833
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
834
|
+
// Case 3: fallback content-compare (no stored hash, pre-v0.17.1 project).
|
|
835
|
+
//
|
|
836
|
+
// Two acceptable "pristine" states for a pre-v0.17.1 project:
|
|
837
|
+
// (a) existing matches the adapted target — the --init path ran the
|
|
838
|
+
// adapter at install time (existing is adapted content)
|
|
839
|
+
// (b) existing matches the RAW template content — the generator.js
|
|
840
|
+
// scaffold path copied the template without running the adapter
|
|
841
|
+
// (existing is raw content)
|
|
842
|
+
//
|
|
843
|
+
// Without case (b), every fresh-scaffolded v0.17.0 project upgrading to
|
|
844
|
+
// v0.17.1 would false-positive-preserve all 4 standards on first upgrade
|
|
845
|
+
// (the template content on disk would not match the adapter output).
|
|
846
|
+
if (
|
|
847
|
+
normalizedContentEquals(existing, freshAdapted) ||
|
|
848
|
+
normalizedContentEquals(existing, template)
|
|
849
|
+
) {
|
|
850
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
851
|
+
fs.writeFileSync(absPath, freshAdapted, 'utf8');
|
|
852
|
+
filesToAdapt.add(spec.posix);
|
|
853
|
+
standardsResults.push({ name: spec.posix, modified: false });
|
|
854
|
+
replaced++;
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
preserveStandard();
|
|
631
858
|
}
|
|
632
859
|
|
|
633
860
|
step('Updated standards files');
|
|
@@ -827,18 +1054,15 @@ function generateUpgrade(config) {
|
|
|
827
1054
|
// mangled by the rule replacements (Codex M1 + plan v1.1 § Allowlist
|
|
828
1055
|
// semantics).
|
|
829
1056
|
//
|
|
830
|
-
// SKILL.md, ticket-template.md, and documentation-standards.mdc
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
}
|
|
840
|
-
filesToAdapt.add(toPosix('ai-specs/specs/documentation-standards.mdc'));
|
|
841
|
-
|
|
1057
|
+
// v0.17.1: SKILL.md, ticket-template.md, and documentation-standards.mdc
|
|
1058
|
+
// are now smart-diff-protected (c2 block and standards block respectively)
|
|
1059
|
+
// and ARE added to filesToAdapt conditionally on being replaced (NOT
|
|
1060
|
+
// preserved). The pre-v0.17.1 unconditional adds that used to live here
|
|
1061
|
+
// were removed because they violated the Codex M1 invariant when the
|
|
1062
|
+
// new smart-diff blocks preserved a customized file (Gemini round-3
|
|
1063
|
+
// finding 1): the unconditional add would re-apply stack adaptations
|
|
1064
|
+
// to the restored user content, mangling it, and then the hash loop
|
|
1065
|
+
// would overwrite the preserved hash.
|
|
842
1066
|
applyStackAdaptations(dest, scan, config, filesToAdapt);
|
|
843
1067
|
|
|
844
1068
|
step('Adapted files for project type and stack');
|
|
@@ -935,16 +1159,19 @@ function generateUpgrade(config) {
|
|
|
935
1159
|
);
|
|
936
1160
|
}
|
|
937
1161
|
console.log(
|
|
938
|
-
`\n Note: this is
|
|
1162
|
+
`\n Note: this is expected on the first v0.17.0+ upgrade from a pre-v0.17.0 project`
|
|
1163
|
+
);
|
|
1164
|
+
console.log(
|
|
1165
|
+
` for files the user had not touched — the fallback content-compare path is`
|
|
939
1166
|
);
|
|
940
1167
|
console.log(
|
|
941
|
-
`
|
|
1168
|
+
` conservative by design. After this upgrade, .sdd-meta.json records the hash of`
|
|
942
1169
|
);
|
|
943
1170
|
console.log(
|
|
944
|
-
`
|
|
1171
|
+
` every SDD-managed file. Subsequent upgrades will use hash-based precision and`
|
|
945
1172
|
);
|
|
946
1173
|
console.log(
|
|
947
|
-
`
|
|
1174
|
+
` will only warn on files the user actually edited.`
|
|
948
1175
|
);
|
|
949
1176
|
console.log(`\n If you have NOT customized these files:`);
|
|
950
1177
|
console.log(
|
|
@@ -972,6 +1199,5 @@ module.exports = {
|
|
|
972
1199
|
readAutonomyLevel,
|
|
973
1200
|
collectCustomAgents,
|
|
974
1201
|
collectCustomCommands,
|
|
975
|
-
isStandardModified,
|
|
976
1202
|
buildSummary,
|
|
977
1203
|
};
|