create-ailk 0.28.0 → 0.28.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/dist/lib/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -0
- package/dist/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/update.d.ts +32 -2
- package/dist/update.js +357 -44
- package/package.json +1 -1
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +1 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +1 -0
- package/templates/tools/declared-env.ts +7 -0
- package/versions.json +1 -1
|
@@ -5,17 +5,33 @@ export interface UnreadChromeKnob {
|
|
|
5
5
|
/** Overlay-relative path of the reader file, with `/` separators. */
|
|
6
6
|
file: string;
|
|
7
7
|
}
|
|
8
|
+
/** One overlay file that reads a knob. */
|
|
9
|
+
export interface ChromeReader {
|
|
10
|
+
/** Overlay-relative path, with `/` separators. */
|
|
11
|
+
file: string;
|
|
12
|
+
/** Present when `file` reads the knob only through its imports: the files it reaches that read it directly. */
|
|
13
|
+
via?: string[];
|
|
14
|
+
}
|
|
8
15
|
/** The pattern that means "this text reads `knob`". */
|
|
9
16
|
export declare function chromeReadPattern(knob: string): RegExp;
|
|
10
17
|
/**
|
|
11
|
-
* knob → the overlay files that read it, sorted
|
|
12
|
-
* fails closed (throws, naming the directory)
|
|
13
|
-
* so a broken overlay can never read as
|
|
18
|
+
* knob → the overlay files that read it, sorted by file; one that reads it
|
|
19
|
+
* only through relative imports carries `via`. Scans `<templatesDir>/apps/web`; fails closed (throws, naming the directory)
|
|
20
|
+
* when it is absent or unreadable, so a broken overlay can never read as
|
|
21
|
+
* "nothing reads any knob".
|
|
14
22
|
*/
|
|
15
|
-
export declare function chromeKnobReaders(templatesDir: string): Map<string,
|
|
23
|
+
export declare function chromeKnobReaders(templatesDir: string): Map<string, ChromeReader[]>;
|
|
16
24
|
/**
|
|
17
25
|
* The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
|
|
18
26
|
* the shipped overlay's `file` reads it, the site has its own `file`, and the
|
|
19
27
|
* site's `file` does not read it. A reader file the site lacks is skipped.
|
|
28
|
+
*
|
|
29
|
+
* A pair the overlay credits only through an import is also satisfied when
|
|
30
|
+
* some site file outside its `via` reads the knob directly: a site from before
|
|
31
|
+
* the read moved into the helper renders it in its own place (#10156). So the
|
|
32
|
+
* import credit adds a pair only for a knob the site reads nowhere but its
|
|
33
|
+
* helpers. It is per file, not per export: a site whose home page drops the
|
|
34
|
+
* helper import is named for every knob that helper reads — a refusal the
|
|
35
|
+
* operator passes with `--allow-unread-chrome`, never a silent miss.
|
|
20
36
|
*/
|
|
21
|
-
export declare function unreadChromeKnobs(resolved: ResolvedSiteChrome, readers: ReadonlyMap<string, readonly
|
|
37
|
+
export declare function unreadChromeKnobs(resolved: ResolvedSiteChrome, readers: ReadonlyMap<string, readonly ChromeReader[]>, siteDir: string): UnreadChromeKnob[];
|
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
* each knob — nothing is hard-coded, so a new reader file is picked up.
|
|
12
12
|
* - `unreadChromeKnobs` compares the site's own copy of each reader file.
|
|
13
13
|
*
|
|
14
|
-
* "
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* A file "reads `knob` directly" when its text matches
|
|
15
|
+
* `(?:siteChrome|chrome)\.<knob>\b`, and "reads" it when it or a file it
|
|
16
|
+
* imports by a relative path, at any depth, reads it directly — so the layout
|
|
17
|
+
* stays a reader of `innerPages` after #10106 moved that read into
|
|
18
|
+
* `lib/page-chrome.ts` (#10156). A file that reads a knob under another name
|
|
19
|
+
* (a destructured alias) is not recognised — accepted (spec § Clarifications,
|
|
20
|
+
* detection).
|
|
17
21
|
*/
|
|
18
22
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
19
|
-
import { join } from "node:path";
|
|
23
|
+
import { join, posix } from "node:path";
|
|
20
24
|
import { DEFAULT_SITE_CHROME, } from "../site-chrome.js";
|
|
21
25
|
/** The overlay tree the generated app files live under. */
|
|
22
26
|
const OVERLAY_WEB_DIR = ["apps", "web"];
|
|
@@ -31,41 +35,126 @@ function listSources(root, dir, out) {
|
|
|
31
35
|
for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
|
|
32
36
|
const rel = `${dir}/${entry.name}`;
|
|
33
37
|
if (entry.isDirectory()) {
|
|
34
|
-
if (entry.name !== "__tests__"
|
|
38
|
+
if (entry.name !== "__tests__" &&
|
|
39
|
+
entry.name !== "node_modules" &&
|
|
40
|
+
!entry.name.startsWith(".")) {
|
|
35
41
|
listSources(root, rel, out);
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
44
|
else if (/\.tsx?$/.test(entry.name)) {
|
|
38
45
|
out.push(rel);
|
|
39
46
|
}
|
|
40
47
|
}
|
|
41
48
|
}
|
|
49
|
+
/** A relative module specifier in an import, export-from or dynamic import. */
|
|
50
|
+
const RELATIVE_IMPORT = /(?:\bfrom|\bimport)\s*\(?\s*["'](\.{1,2}\/[^"']+)["']/g;
|
|
51
|
+
function sourceTree(root) {
|
|
52
|
+
const texts = new Map();
|
|
53
|
+
const closures = new Map();
|
|
54
|
+
const importLists = new Map();
|
|
55
|
+
const text = (file) => {
|
|
56
|
+
if (!texts.has(file)) {
|
|
57
|
+
const abs = join(root, file);
|
|
58
|
+
texts.set(file, existsSync(abs) ? readFileSync(abs, "utf8") : null);
|
|
59
|
+
}
|
|
60
|
+
return texts.get(file);
|
|
61
|
+
};
|
|
62
|
+
/** The files `file` imports by a relative path that exist under the root; `./x.js` may name `x.ts`. */
|
|
63
|
+
const imports = (file) => {
|
|
64
|
+
const cached = importLists.get(file);
|
|
65
|
+
if (cached !== undefined)
|
|
66
|
+
return cached;
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const match of (text(file) ?? "").matchAll(RELATIVE_IMPORT)) {
|
|
69
|
+
const base = posix
|
|
70
|
+
.normalize(posix.join(posix.dirname(file), match[1]))
|
|
71
|
+
.replace(/\/$/, "");
|
|
72
|
+
if (base.startsWith(".."))
|
|
73
|
+
continue;
|
|
74
|
+
const stem = base.replace(/\.(?:js|jsx)$/, "");
|
|
75
|
+
const hit = [
|
|
76
|
+
base,
|
|
77
|
+
`${stem}.ts`,
|
|
78
|
+
`${stem}.tsx`,
|
|
79
|
+
`${stem}/index.ts`,
|
|
80
|
+
`${stem}/index.tsx`,
|
|
81
|
+
].find((candidate) => /\.tsx?$/.test(candidate) && text(candidate) !== null);
|
|
82
|
+
if (hit !== undefined)
|
|
83
|
+
out.push(hit);
|
|
84
|
+
}
|
|
85
|
+
importLists.set(file, out);
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
const reached = (file) => {
|
|
89
|
+
let closure = closures.get(file);
|
|
90
|
+
if (closure === undefined) {
|
|
91
|
+
closure = new Set();
|
|
92
|
+
const stack = [...imports(file)];
|
|
93
|
+
while (stack.length > 0) {
|
|
94
|
+
const next = stack.pop();
|
|
95
|
+
if (next === file || closure.has(next))
|
|
96
|
+
continue;
|
|
97
|
+
closure.add(next);
|
|
98
|
+
stack.push(...imports(next));
|
|
99
|
+
}
|
|
100
|
+
closures.set(file, closure);
|
|
101
|
+
}
|
|
102
|
+
return closure;
|
|
103
|
+
};
|
|
104
|
+
const readsDirectly = (file, pattern) => pattern.test(text(file) ?? "");
|
|
105
|
+
return {
|
|
106
|
+
text,
|
|
107
|
+
reached,
|
|
108
|
+
readsDirectly,
|
|
109
|
+
reads: (file, pattern) => readsDirectly(file, pattern) ||
|
|
110
|
+
[...reached(file)].some((dep) => readsDirectly(dep, pattern)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
42
113
|
/**
|
|
43
|
-
* knob → the overlay files that read it, sorted
|
|
44
|
-
* fails closed (throws, naming the directory)
|
|
45
|
-
* so a broken overlay can never read as
|
|
114
|
+
* knob → the overlay files that read it, sorted by file; one that reads it
|
|
115
|
+
* only through relative imports carries `via`. Scans `<templatesDir>/apps/web`; fails closed (throws, naming the directory)
|
|
116
|
+
* when it is absent or unreadable, so a broken overlay can never read as
|
|
117
|
+
* "nothing reads any knob".
|
|
46
118
|
*/
|
|
47
119
|
export function chromeKnobReaders(templatesDir) {
|
|
48
120
|
const webDir = join(templatesDir, ...OVERLAY_WEB_DIR);
|
|
49
|
-
const
|
|
121
|
+
const direct = new Map();
|
|
122
|
+
const files = [];
|
|
123
|
+
const tree = sourceTree(templatesDir);
|
|
50
124
|
try {
|
|
51
125
|
if (!existsSync(webDir))
|
|
52
126
|
throw new Error("missing");
|
|
53
|
-
const files = [];
|
|
54
127
|
listSources(templatesDir, OVERLAY_WEB_DIR.join("/"), files);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
for (const match of
|
|
128
|
+
files.sort();
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
for (const match of tree
|
|
131
|
+
.text(file)
|
|
132
|
+
.matchAll(new RegExp(`${READ_HEAD}([A-Za-z_$][\\w$]*)\\b`, "g"))) {
|
|
58
133
|
const knob = match[1];
|
|
59
|
-
const list =
|
|
134
|
+
const list = direct.get(knob) ?? [];
|
|
60
135
|
if (!list.includes(file))
|
|
61
136
|
list.push(file);
|
|
62
|
-
|
|
137
|
+
direct.set(knob, list);
|
|
63
138
|
}
|
|
64
139
|
}
|
|
65
140
|
}
|
|
66
141
|
catch {
|
|
67
142
|
throw new Error(`cannot check chrome knobs: the overlay has no readable apps/web at ${webDir}. Reinstall create-ailk.`);
|
|
68
143
|
}
|
|
144
|
+
const readers = new Map();
|
|
145
|
+
for (const [knob, directFiles] of direct) {
|
|
146
|
+
const list = [];
|
|
147
|
+
for (const file of files) {
|
|
148
|
+
if (directFiles.includes(file)) {
|
|
149
|
+
list.push({ file });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const via = directFiles.filter((reader) => tree.reached(file).has(reader));
|
|
153
|
+
if (via.length > 0)
|
|
154
|
+
list.push({ file, via });
|
|
155
|
+
}
|
|
156
|
+
readers.set(knob, list);
|
|
157
|
+
}
|
|
69
158
|
return readers;
|
|
70
159
|
}
|
|
71
160
|
/** A knob is set when it differs from its default; an optional knob (no default) when present. */
|
|
@@ -81,19 +170,38 @@ function isSet(resolved, knob) {
|
|
|
81
170
|
* The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
|
|
82
171
|
* the shipped overlay's `file` reads it, the site has its own `file`, and the
|
|
83
172
|
* site's `file` does not read it. A reader file the site lacks is skipped.
|
|
173
|
+
*
|
|
174
|
+
* A pair the overlay credits only through an import is also satisfied when
|
|
175
|
+
* some site file outside its `via` reads the knob directly: a site from before
|
|
176
|
+
* the read moved into the helper renders it in its own place (#10156). So the
|
|
177
|
+
* import credit adds a pair only for a knob the site reads nowhere but its
|
|
178
|
+
* helpers. It is per file, not per export: a site whose home page drops the
|
|
179
|
+
* helper import is named for every knob that helper reads — a refusal the
|
|
180
|
+
* operator passes with `--allow-unread-chrome`, never a silent miss.
|
|
84
181
|
*/
|
|
85
182
|
export function unreadChromeKnobs(resolved, readers, siteDir) {
|
|
86
183
|
const out = [];
|
|
184
|
+
const site = sourceTree(siteDir);
|
|
185
|
+
let siteFiles;
|
|
87
186
|
for (const knob of Object.keys(resolved).sort()) {
|
|
88
187
|
if (!isSet(resolved, knob))
|
|
89
188
|
continue;
|
|
90
189
|
const pattern = chromeReadPattern(knob);
|
|
91
|
-
for (const file of readers.get(knob) ?? []) {
|
|
92
|
-
|
|
93
|
-
if (!existsSync(abs))
|
|
190
|
+
for (const { file, via } of readers.get(knob) ?? []) {
|
|
191
|
+
if (site.text(file) === null || site.reads(file, pattern))
|
|
94
192
|
continue;
|
|
95
|
-
if (
|
|
96
|
-
|
|
193
|
+
if (via !== undefined) {
|
|
194
|
+
if (siteFiles === undefined) {
|
|
195
|
+
siteFiles = [];
|
|
196
|
+
if (existsSync(join(siteDir, ...OVERLAY_WEB_DIR))) {
|
|
197
|
+
listSources(siteDir, OVERLAY_WEB_DIR.join("/"), siteFiles);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const elsewhere = siteFiles.some((other) => !via.includes(other) && site.readsDirectly(other, pattern));
|
|
201
|
+
if (elsewhere)
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
out.push({ knob, file });
|
|
97
205
|
}
|
|
98
206
|
}
|
|
99
207
|
return out;
|
|
@@ -109,6 +109,12 @@ export interface PlanEnvRowsOptions {
|
|
|
109
109
|
* with no reader and so no `scope`; a platform or refused name is skipped.
|
|
110
110
|
*/
|
|
111
111
|
declare?: readonly string[];
|
|
112
|
+
/**
|
|
113
|
+
* When set, only reads in these site-relative files get a row (#10173):
|
|
114
|
+
* `update` declares what the files it wrote read, never a name only the
|
|
115
|
+
* site's own code reads.
|
|
116
|
+
*/
|
|
117
|
+
onlyFiles?: ReadonlySet<string>;
|
|
112
118
|
}
|
|
113
119
|
/**
|
|
114
120
|
* `projectYamlText` with a row appended for every name the site at `root`
|
package/dist/lib/declared-env.js
CHANGED
|
@@ -904,6 +904,8 @@ export function planEnvRows(root, projectYamlText, opts) {
|
|
|
904
904
|
const refused = new Set(opts.topology === "spoke" ? SPOKE_REFUSED_ENV : []);
|
|
905
905
|
const files = new Map();
|
|
906
906
|
for (const read of collectSiteReads(root).reads) {
|
|
907
|
+
if (opts.onlyFiles !== undefined && !opts.onlyFiles.has(read.file))
|
|
908
|
+
continue;
|
|
907
909
|
if (isPlatformEnvName(read.name) || refused.has(read.name))
|
|
908
910
|
continue;
|
|
909
911
|
if (declared.has(read.name))
|
|
@@ -27,3 +27,19 @@ export declare function resolveRelativeImport(fromFile: string, spec: string, ca
|
|
|
27
27
|
* @param seeds repo-relative, "/"-separated files the closure starts from.
|
|
28
28
|
*/
|
|
29
29
|
export declare function importClosure(repoRoot: string, roots: readonly string[], seeds: Iterable<string>): Set<string>;
|
|
30
|
+
/**
|
|
31
|
+
* The names a module imports from each specifier, textually (#10173): the
|
|
32
|
+
* members of a `{ … }` list (an `a as b` member names `a`, a `type a` member
|
|
33
|
+
* `a`) and `default` for a default import. A namespace import (`* as x`) and
|
|
34
|
+
* a side-effect import name nothing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function namedImportsOf(text: string): Array<{
|
|
37
|
+
specifier: string;
|
|
38
|
+
names: string[];
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Whether module `text` exports `name`, textually. Fails toward `true` — an
|
|
42
|
+
* `export *`, or a destructuring export, could export anything — so a caller
|
|
43
|
+
* that holds a file back on `false` never holds one that does export it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function exportsName(text: string, name: string): boolean;
|
package/dist/lib/import-graph.js
CHANGED
|
@@ -138,3 +138,56 @@ export function importClosure(repoRoot, roots, seeds) {
|
|
|
138
138
|
}
|
|
139
139
|
return closed;
|
|
140
140
|
}
|
|
141
|
+
const IDENT = "[A-Za-z_$][\\w$]*";
|
|
142
|
+
/** `import { a, b as c }` / `export { a } from` / `import X from` — the bound names per specifier. */
|
|
143
|
+
const NAMED_IMPORT = new RegExp(`\\b(?:import|export)\\s+(?:type\\s+)?(?:(${IDENT})\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*["']([^"']+)["']` +
|
|
144
|
+
`|\\bimport\\s+(?:type\\s+)?(${IDENT})\\s+from\\s*["']([^"']+)["']`, "g");
|
|
145
|
+
/**
|
|
146
|
+
* The names a module imports from each specifier, textually (#10173): the
|
|
147
|
+
* members of a `{ … }` list (an `a as b` member names `a`, a `type a` member
|
|
148
|
+
* `a`) and `default` for a default import. A namespace import (`* as x`) and
|
|
149
|
+
* a side-effect import name nothing.
|
|
150
|
+
*/
|
|
151
|
+
export function namedImportsOf(text) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const m of text.matchAll(NAMED_IMPORT)) {
|
|
154
|
+
if (m[3] !== undefined) {
|
|
155
|
+
const names = (m[2] ?? "")
|
|
156
|
+
.split(",")
|
|
157
|
+
.map((member) => member.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim())
|
|
158
|
+
.filter((name) => new RegExp(`^${IDENT}$`).test(name));
|
|
159
|
+
if (m[1] !== undefined)
|
|
160
|
+
names.push("default");
|
|
161
|
+
out.push({ specifier: m[3], names });
|
|
162
|
+
}
|
|
163
|
+
else if (m[5] !== undefined) {
|
|
164
|
+
out.push({ specifier: m[5], names: ["default"] });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether module `text` exports `name`, textually. Fails toward `true` — an
|
|
171
|
+
* `export *`, or a destructuring export, could export anything — so a caller
|
|
172
|
+
* that holds a file back on `false` never holds one that does export it.
|
|
173
|
+
*/
|
|
174
|
+
export function exportsName(text, name) {
|
|
175
|
+
if (/\bexport\s*\*/.test(text))
|
|
176
|
+
return true;
|
|
177
|
+
if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(text))
|
|
178
|
+
return true;
|
|
179
|
+
if (name === "default" && /\bexport\s+default\b/.test(text))
|
|
180
|
+
return true;
|
|
181
|
+
const declared = new RegExp(`\\bexport\\s+(?:declare\\s+)?(?:async\\s+)?(?:abstract\\s+)?` +
|
|
182
|
+
`(?:const\\s+enum|const|let|var|function\\s*\\*?|class|type|interface|enum|namespace)\\s*${name.replace(/\$/g, "\\$")}\\b`);
|
|
183
|
+
if (declared.test(text))
|
|
184
|
+
return true;
|
|
185
|
+
for (const m of text.matchAll(/\bexport\s*(?:type\s*)?\{([^}]*)\}/g)) {
|
|
186
|
+
for (const member of (m[1] ?? "").split(",")) {
|
|
187
|
+
const parts = member.trim().replace(/^type\s+/, "").split(/\s+as\s+/);
|
|
188
|
+
if ((parts[1] ?? parts[0])?.trim() === name)
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
@@ -49,14 +49,23 @@ export interface TemplateRecord {
|
|
|
49
49
|
export declare class TemplateRecordError extends Error {
|
|
50
50
|
constructor(message: string);
|
|
51
51
|
}
|
|
52
|
+
export interface ParseTemplateRecordOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Called with each traversal-safe `files` key that is not a recordable path
|
|
55
|
+
* — a path a later create-ailk stopped recording, such as a generated file
|
|
56
|
+
* (#10173) — instead of refusing the record. The row is left out of the
|
|
57
|
+
* result. An unsafe key still refuses.
|
|
58
|
+
*/
|
|
59
|
+
onUnrecordable?: (key: string) => void;
|
|
60
|
+
}
|
|
52
61
|
/**
|
|
53
62
|
* Parse `.ailk-template-files.json` text, refusing — naming the field — on:
|
|
54
63
|
* JSON that does not parse; `schema !== 1`; an unknown top-level key; an
|
|
55
|
-
* empty `projectName`; a `files` key that is not a recordable path
|
|
56
|
-
* `sha256` that is not 64 lowercase hex; a
|
|
57
|
-
* string (#8522 D1).
|
|
64
|
+
* empty `projectName`; a `files` key that is not a recordable path (unless
|
|
65
|
+
* `onUnrecordable` takes it); a `sha256` that is not 64 lowercase hex; a
|
|
66
|
+
* `version` that is not a semver string (#8522 D1).
|
|
58
67
|
*/
|
|
59
|
-
export declare function parseTemplateRecord(text: string): TemplateRecord;
|
|
68
|
+
export declare function parseTemplateRecord(text: string, opts?: ParseTemplateRecordOptions): TemplateRecord;
|
|
60
69
|
/** `record` as `scaffold()` and `update` write it: sorted keys, trailing newline. */
|
|
61
70
|
export declare function serializeTemplateRecord(record: TemplateRecord): string;
|
|
62
71
|
export interface PlanTemplateRecordOptions {
|
package/dist/template-record.js
CHANGED
|
@@ -128,11 +128,11 @@ function isSafeRecordKey(key) {
|
|
|
128
128
|
/**
|
|
129
129
|
* Parse `.ailk-template-files.json` text, refusing — naming the field — on:
|
|
130
130
|
* JSON that does not parse; `schema !== 1`; an unknown top-level key; an
|
|
131
|
-
* empty `projectName`; a `files` key that is not a recordable path
|
|
132
|
-
* `sha256` that is not 64 lowercase hex; a
|
|
133
|
-
* string (#8522 D1).
|
|
131
|
+
* empty `projectName`; a `files` key that is not a recordable path (unless
|
|
132
|
+
* `onUnrecordable` takes it); a `sha256` that is not 64 lowercase hex; a
|
|
133
|
+
* `version` that is not a semver string (#8522 D1).
|
|
134
134
|
*/
|
|
135
|
-
export function parseTemplateRecord(text) {
|
|
135
|
+
export function parseTemplateRecord(text, opts = {}) {
|
|
136
136
|
let doc;
|
|
137
137
|
try {
|
|
138
138
|
doc = JSON.parse(text);
|
|
@@ -167,6 +167,10 @@ export function parseTemplateRecord(text) {
|
|
|
167
167
|
throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files" has an unsafe key "${key}"`);
|
|
168
168
|
}
|
|
169
169
|
if (!isRecordable(key)) {
|
|
170
|
+
if (opts.onUnrecordable !== undefined) {
|
|
171
|
+
opts.onUnrecordable(key);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
170
174
|
throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files" has a key "${key}" that is not a recordable path`);
|
|
171
175
|
}
|
|
172
176
|
if (typeof value !== "object" || value === null) {
|
package/dist/update.d.ts
CHANGED
|
@@ -40,11 +40,24 @@ export interface UpdateResult {
|
|
|
40
40
|
path: string;
|
|
41
41
|
reason: string;
|
|
42
42
|
}>;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Per file left under review or skipped — or a relative import the written
|
|
45
|
+
* code names that the site will not have (`missing`) — the template tests
|
|
46
|
+
* (#8704) and the other template files (`importers`, #10173) held with it
|
|
47
|
+
* — the files that import it, or, when it is a file staying at the site's
|
|
48
|
+
* version, the written files it imports — which keep the site's version so
|
|
49
|
+
* the tree never imports what it lacks.
|
|
50
|
+
*/
|
|
44
51
|
heldBack: Array<{
|
|
45
52
|
file: string;
|
|
46
53
|
tests: string[];
|
|
54
|
+
importers?: string[];
|
|
55
|
+
missing?: true;
|
|
47
56
|
}>;
|
|
57
|
+
/** The `project.yaml` `env_contract` rows added for env vars the written files read (#10172). */
|
|
58
|
+
envRowsAdded?: string[];
|
|
59
|
+
/** Record rows dropped because the path is no longer recordable (#10173). */
|
|
60
|
+
droppedRows?: string[];
|
|
48
61
|
/** The mdx-body pages under `content/` given a `# ` heading this run (#7495). */
|
|
49
62
|
headingsAdded: string[];
|
|
50
63
|
backfilled?: {
|
|
@@ -52,6 +65,23 @@ export interface UpdateResult {
|
|
|
52
65
|
files: number;
|
|
53
66
|
};
|
|
54
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The site's messages catalog with every key the template's copy has and it
|
|
70
|
+
* lacks (#10173): the site's values always win, nothing is removed, and the
|
|
71
|
+
* keys it gains follow its own. A key that is an object on one side and a
|
|
72
|
+
* value on the other is a conflict, and nothing is merged.
|
|
73
|
+
*/
|
|
74
|
+
export declare function mergeMessagesAdditively(ours: string, theirs: string): {
|
|
75
|
+
status: "merged";
|
|
76
|
+
text: string;
|
|
77
|
+
added: number;
|
|
78
|
+
} | {
|
|
79
|
+
status: "conflict";
|
|
80
|
+
keys: string[];
|
|
81
|
+
} | {
|
|
82
|
+
status: "invalid";
|
|
83
|
+
reason: string;
|
|
84
|
+
};
|
|
55
85
|
export declare function updateSite(opts: UpdateSiteOptions): Promise<UpdateResult>;
|
|
56
86
|
/** The printed report (D9): one section per non-empty list, in order. */
|
|
57
87
|
export declare function formatUpdateReport(result: UpdateResult): string;
|
|
@@ -59,6 +89,6 @@ export interface UpdateArgs {
|
|
|
59
89
|
target: string | undefined;
|
|
60
90
|
help: boolean;
|
|
61
91
|
}
|
|
62
|
-
export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file, or a root tools/jest file the\napp jest configs require, whose template copy changed is overwritten. An\nedited file whose template copy also changed is three-way merged with\n`git` (git must be on PATH); a conflict leaves the file alone and is\nlisted under \"Needs review\". Every template
|
|
92
|
+
export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file, or a root tools/jest file the\napp jest configs require, whose template copy changed is overwritten. An\nedited file whose template copy also changed is three-way merged with\n`git` (git must be on PATH); a conflict leaves the file alone and is\nlisted under \"Needs review\"; an apps/web/messages catalog instead gains the\ntemplate's new keys and keeps every value it has, and is reviewed only when a\nkey is an object on one side and a value on the other. Every template file,\ntest or not, that imports a file left under \"Needs review\" or \"Skipped\" (by a\nrelative or @/ import, transitively) is held at the site's version with it,\nits record entry is not advanced, and it is listed under \"Held back with\n<file>\" \u2014 reconcile the file, then run update again to bring it forward. A\nwritten file whose import names a file the site will not have is held the\nsame way, under \"Held back with <file> (not in this site)\". Every other file\nin the run still updates. When project.yaml has an env_contract block, each\nenv var the written files read that has no row gains one; no row is edited.\nA record row at a path create-ailk no longer records is dropped and listed.\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. A site scaffolded before\n@working-theory/mcp-server has its copied apps/mcp moved to the boot set:\nthe server and each module's paid MCP pack join apps/mcp/package.json, the\ncopied server files go, and apps/mcp/src/lib/paid-tools.generated.ts is\nrewritten on every run. A site scaffolded before @working-theory/api has its\ncopied apps/api moved to the boot file: the app becomes @working-theory/app-api,\nthe package and the six @working-theory/api-<module> packages join\napps/api/package.json, its bins and build:vercel delegate to the package, and\nthe copied server files go. Run `pnpm install` after either move, since the\napp's package.json gained dependencies. Each mdx-body page in content/ with no\n'# ' heading gets its title and description written into its body as its\nheading and intro, listed under \"Page heading added\"; a page with one heading\nis left as it is, and one with several, or one whose frontmatter it cannot\nread, is listed under \"Needs review\". Commit before running, and review the\ndiff after.\n";
|
|
63
93
|
/** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
|
|
64
94
|
export declare function parseUpdateArgs(argv: string[]): UpdateArgs;
|
package/dist/update.js
CHANGED
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
* version's copy — read from this package's own `templates/` when that is
|
|
11
11
|
* the running version, else re-materialised from the published npm tarball
|
|
12
12
|
* (`lib/fetch-template-snapshot.ts`) — and the running version's copy. A
|
|
13
|
-
* conflict leaves the file untouched and is listed under "Needs review"
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* conflict leaves the file untouched and is listed under "Needs review" — but
|
|
14
|
+
* a `apps/web/messages/` catalog takes the template's new keys and keeps the
|
|
15
|
+
* site's values, reviewed only on a key-level conflict (#10173). Every
|
|
16
|
+
* template file this run would write that imports, transitively, a file left
|
|
17
|
+
* under review or skipped — or a file the site will not have — is held at the
|
|
18
|
+
* site's version with it, so a run never writes code newer than what it
|
|
19
|
+
* imports (#8704 for tests, #10173 for the rest). The env vars the written
|
|
20
|
+
* files read gain `project.yaml` `env_contract` rows (#10172), and a record
|
|
21
|
+
* row at a path no longer recorded is dropped, not refused (#10173). A site
|
|
22
|
+
* with no record gets one backfilled from
|
|
17
23
|
* `.ailk-site-manifest.json`'s `patternVersion` on its first run — one row
|
|
18
24
|
* per in-scope path of that version's snapshot, on disk or not (#9527) —
|
|
19
25
|
* then updates normally. A site scaffolded before `@working-theory/mcp-server`
|
|
@@ -35,13 +41,16 @@
|
|
|
35
41
|
* Pure module: no `import.meta.url` — the CLI supplies `templatesDir`, the
|
|
36
42
|
* `adopt.ts` / `add-route.ts` split.
|
|
37
43
|
*/
|
|
38
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync, } from "node:fs";
|
|
44
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
|
|
39
45
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
40
46
|
import { parseArgs } from "node:util";
|
|
41
47
|
import { AddModuleRefusedError, readArchitectureHeader } from "./add-module-core.js";
|
|
42
48
|
import { headingViolations, mdxBodyRule, migrateBodyHeadings } from "./body-heading.js";
|
|
43
49
|
import { buildScaffoldMarkers, isExcluded, isTextFile, renderTemplateFile, templateDestination } from "./copy.js";
|
|
50
|
+
import { EnvContractError, planEnvRows, readEnvContract, readManifestTopology, } from "./lib/declared-env.js";
|
|
51
|
+
import { ENV_CATALOG } from "./lib/env-catalog.js";
|
|
44
52
|
import { fetchTemplateSnapshot } from "./lib/fetch-template-snapshot.js";
|
|
53
|
+
import { exportsName, listFilesUnder, namedImportsOf } from "./lib/import-graph.js";
|
|
45
54
|
import { mergeThreeWay } from "./lib/merge-three-way.js";
|
|
46
55
|
import { publicTreeRemoved } from "./lib/site-modules.js";
|
|
47
56
|
import { MODULE_SLICES } from "./module-architecture.js";
|
|
@@ -170,6 +179,7 @@ function containsKnownPath(prefix, knownPaths) {
|
|
|
170
179
|
const TEST_FILE = /(^|\/)__tests__\/|\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
171
180
|
/** Module specifiers `import`/`export … from`, `import()`, `require()` and `jest.mock()` name. */
|
|
172
181
|
const IMPORT_SPECIFIER = /\bfrom\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']|\b(?:import|require|jest\.mock|jest\.requireActual)\s*\(\s*["']([^"']+)["']/g;
|
|
182
|
+
const WEB_ROOT = "apps/web/";
|
|
173
183
|
const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
|
|
174
184
|
/**
|
|
175
185
|
* The record-style paths a relative specifier in `fromPath` can resolve to:
|
|
@@ -177,8 +187,10 @@ const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs"
|
|
|
177
187
|
* swapped for its TypeScript source, and as a directory's `index.*`.
|
|
178
188
|
*/
|
|
179
189
|
function relativeImportCandidates(fromPath, specifier) {
|
|
180
|
-
|
|
181
|
-
|
|
190
|
+
// `@/x` is apps/web's `x` — its tsconfig `paths`; no other app declares one.
|
|
191
|
+
const alias = specifier.startsWith("@/") && fromPath.startsWith(WEB_ROOT);
|
|
192
|
+
const parts = alias ? ["apps", "web"] : fromPath.split("/").slice(0, -1);
|
|
193
|
+
for (const segment of (alias ? specifier.slice(2) : specifier).split("/")) {
|
|
182
194
|
if (segment === "" || segment === ".")
|
|
183
195
|
continue;
|
|
184
196
|
if (segment === "..")
|
|
@@ -196,13 +208,24 @@ function relativeImportCandidates(fromPath, specifier) {
|
|
|
196
208
|
out.push(...RESOLVE_EXTENSIONS.map((ext) => `${target}/index${ext}`));
|
|
197
209
|
return out;
|
|
198
210
|
}
|
|
199
|
-
/**
|
|
200
|
-
function
|
|
211
|
+
/** Whether `specifier` names a site file: relative, or `@/` from an apps/web file. */
|
|
212
|
+
function isLocal(fromPath, specifier) {
|
|
213
|
+
return specifier.startsWith(".") || (specifier.startsWith("@/") && fromPath.startsWith(WEB_ROOT));
|
|
214
|
+
}
|
|
215
|
+
/** The local module specifiers `text` names. */
|
|
216
|
+
function localSpecifiers(fromPath, text) {
|
|
201
217
|
const out = new Set();
|
|
202
218
|
for (const match of text.matchAll(IMPORT_SPECIFIER)) {
|
|
203
219
|
const specifier = match[1] ?? match[2] ?? match[3];
|
|
204
|
-
if (specifier
|
|
205
|
-
|
|
220
|
+
if (specifier !== undefined && isLocal(fromPath, specifier))
|
|
221
|
+
out.add(specifier);
|
|
222
|
+
}
|
|
223
|
+
return [...out];
|
|
224
|
+
}
|
|
225
|
+
/** The files of `held` that `text`, the source of `fromPath`, imports by local specifier. */
|
|
226
|
+
function importedHeldFiles(fromPath, text, held) {
|
|
227
|
+
const out = new Set();
|
|
228
|
+
for (const specifier of localSpecifiers(fromPath, text)) {
|
|
206
229
|
for (const candidate of relativeImportCandidates(fromPath, specifier)) {
|
|
207
230
|
if (held.has(candidate))
|
|
208
231
|
out.add(candidate);
|
|
@@ -210,6 +233,57 @@ function importedHeldFiles(fromPath, text, held) {
|
|
|
210
233
|
}
|
|
211
234
|
return [...out];
|
|
212
235
|
}
|
|
236
|
+
/** The `apps/web/messages/` catalogs next-intl loads by locale, never by import. */
|
|
237
|
+
const MESSAGES_JSON = /^apps\/web\/messages\/(?:[^/]+\/)*[^/]+\.json$/;
|
|
238
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
239
|
+
function isPlainObject(value) {
|
|
240
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The site's messages catalog with every key the template's copy has and it
|
|
244
|
+
* lacks (#10173): the site's values always win, nothing is removed, and the
|
|
245
|
+
* keys it gains follow its own. A key that is an object on one side and a
|
|
246
|
+
* value on the other is a conflict, and nothing is merged.
|
|
247
|
+
*/
|
|
248
|
+
export function mergeMessagesAdditively(ours, theirs) {
|
|
249
|
+
let oursDoc;
|
|
250
|
+
let theirsDoc;
|
|
251
|
+
try {
|
|
252
|
+
oursDoc = JSON.parse(ours);
|
|
253
|
+
theirsDoc = JSON.parse(theirs);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
return { status: "invalid", reason: `not JSON: ${err.message}` };
|
|
257
|
+
}
|
|
258
|
+
if (!isPlainObject(oursDoc) || !isPlainObject(theirsDoc)) {
|
|
259
|
+
return { status: "invalid", reason: "the root is not an object" };
|
|
260
|
+
}
|
|
261
|
+
const conflicts = [];
|
|
262
|
+
let added = 0;
|
|
263
|
+
const walk = (o, t, at) => {
|
|
264
|
+
for (const [key, value] of Object.entries(t)) {
|
|
265
|
+
if (UNSAFE_KEYS.has(key))
|
|
266
|
+
continue; // never written through, so never a prototype change
|
|
267
|
+
const path = at === "" ? key : `${at}.${key}`;
|
|
268
|
+
if (!Object.prototype.hasOwnProperty.call(o, key)) {
|
|
269
|
+
o[key] = value;
|
|
270
|
+
added++;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const mine = o[key];
|
|
274
|
+
if (isPlainObject(mine) && isPlainObject(value))
|
|
275
|
+
walk(mine, value, path);
|
|
276
|
+
else if (isPlainObject(mine) || isPlainObject(value))
|
|
277
|
+
conflicts.push(path);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
walk(oursDoc, theirsDoc, "");
|
|
281
|
+
if (conflicts.length > 0)
|
|
282
|
+
return { status: "conflict", keys: conflicts };
|
|
283
|
+
const indent = /\n(\t| +)"/.exec(ours)?.[1] ?? 2;
|
|
284
|
+
const text = JSON.stringify(oursDoc, null, indent) + (ours.endsWith("\n") ? "\n" : "");
|
|
285
|
+
return { status: "merged", text: added === 0 ? ours : text, added };
|
|
286
|
+
}
|
|
213
287
|
// ── The MCP move (#9567): a copied apps/mcp becomes the boot set ──────────
|
|
214
288
|
const MCP_SERVER_PACKAGE = "@working-theory/mcp-server";
|
|
215
289
|
const MCP_PACKAGE_JSON = "apps/mcp/package.json";
|
|
@@ -789,9 +863,14 @@ export async function updateSite(opts) {
|
|
|
789
863
|
const recordPath = join(targetDir, TEMPLATE_RECORD_PATH);
|
|
790
864
|
let record;
|
|
791
865
|
let backfillPatternVersion;
|
|
866
|
+
// #10173 — a row at a path a later create-ailk no longer records (a
|
|
867
|
+
// generated file) is dropped and reported, never a refusal of the run.
|
|
868
|
+
const droppedRows = [];
|
|
792
869
|
if (existsSync(recordPath)) {
|
|
793
870
|
try {
|
|
794
|
-
record = parseTemplateRecord(readFileSync(recordPath, "utf8")
|
|
871
|
+
record = parseTemplateRecord(readFileSync(recordPath, "utf8"), {
|
|
872
|
+
onUnrecordable: (key) => droppedRows.push(key),
|
|
873
|
+
});
|
|
795
874
|
}
|
|
796
875
|
catch (err) {
|
|
797
876
|
throw new UpdateRefusedError(err.message);
|
|
@@ -1081,6 +1160,26 @@ export async function updateSite(opts) {
|
|
|
1081
1160
|
nextFiles[path] = { sha256: newHash, version };
|
|
1082
1161
|
merged.push(path);
|
|
1083
1162
|
}
|
|
1163
|
+
else if (mergeResult.status === "conflict" && MESSAGES_JSON.test(path)) {
|
|
1164
|
+
// #10173 — a messages catalog takes the template's new keys and keeps
|
|
1165
|
+
// every value the site has; only a key-level conflict is reviewed.
|
|
1166
|
+
const additive = mergeMessagesAdditively(diskBytes.toString("utf8"), Buffer.from(newBytes).toString("utf8"));
|
|
1167
|
+
if (additive.status === "conflict") {
|
|
1168
|
+
review.push({
|
|
1169
|
+
path,
|
|
1170
|
+
reason: `conflicts with the template change ${rec.version} → ${version} at key ${additive.keys.join(", ")}`,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
else if (additive.status === "invalid") {
|
|
1174
|
+
review.push({ path, reason: `the additive pass could not run: ${additive.reason}` });
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
if (additive.added > 0)
|
|
1178
|
+
writes.push({ path, bytes: Buffer.from(additive.text, "utf8") });
|
|
1179
|
+
nextFiles[path] = { sha256: newHash, version };
|
|
1180
|
+
merged.push(path);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1084
1183
|
else if (mergeResult.status === "conflict") {
|
|
1085
1184
|
review.push({
|
|
1086
1185
|
path,
|
|
@@ -1151,46 +1250,209 @@ export async function updateSite(opts) {
|
|
|
1151
1250
|
// exercising the held file is enough. A held test-support file
|
|
1152
1251
|
// (`__tests__/fixtures.ts`) holds the tests importing it in turn, until no
|
|
1153
1252
|
// further test is held.
|
|
1253
|
+
//
|
|
1254
|
+
// #10173 — the same holds for every other template file this run would
|
|
1255
|
+
// write: one importing a held file stays at the site's version (or, when
|
|
1256
|
+
// new, is not added), so the written tree never imports an export the held
|
|
1257
|
+
// file lacks. A written file whose relative import names a file the site
|
|
1258
|
+
// will not have once the run lands is held too, under that missing path.
|
|
1259
|
+
// Only the bytes being written are read for a non-test file: the site's
|
|
1260
|
+
// own copy already compiles against what it has.
|
|
1154
1261
|
const heldFiles = new Set([...review, ...skipped].map((row) => row.path));
|
|
1262
|
+
// The files left alone outright, before any hold-back joins them.
|
|
1263
|
+
const leftAlone = new Set(heldFiles);
|
|
1264
|
+
const writtenBytes = new Map(writes.map((w) => [w.path, w.bytes]));
|
|
1265
|
+
// The moves' removals (#9567, #9566) land too: never code that stays.
|
|
1266
|
+
const deleting = new Set([...deletes, ...(mcpMove?.deletes ?? []), ...(apiMove?.deletes ?? [])]);
|
|
1267
|
+
const adding = new Set(added);
|
|
1155
1268
|
const heldBackByFile = new Map();
|
|
1156
|
-
const
|
|
1157
|
-
const moving = [...updated, ...merged, ...added, ...removed, ...current]
|
|
1158
|
-
|
|
1269
|
+
const heldPaths = new Set();
|
|
1270
|
+
const moving = [...new Set([...updated, ...merged, ...added, ...removed, ...current])].sort();
|
|
1271
|
+
const isFileCache = new Map();
|
|
1272
|
+
const isFileOnDisk = (path) => {
|
|
1273
|
+
let hit = isFileCache.get(path);
|
|
1274
|
+
if (hit === undefined) {
|
|
1275
|
+
try {
|
|
1276
|
+
hit = statSync(join(targetDir, ...path.split("/"))).isFile();
|
|
1277
|
+
}
|
|
1278
|
+
catch {
|
|
1279
|
+
hit = false;
|
|
1280
|
+
}
|
|
1281
|
+
isFileCache.set(path, hit);
|
|
1282
|
+
}
|
|
1283
|
+
return hit;
|
|
1284
|
+
};
|
|
1285
|
+
// What the run rewrites after the record's writes, never from the template.
|
|
1286
|
+
const rewrittenLater = new Set([
|
|
1287
|
+
MODULE_FLAGS_PATH,
|
|
1288
|
+
PAID_MCP_TOOLS_PATH,
|
|
1289
|
+
...(mcpMove?.seam !== undefined ? [MCP_SEAM_PATH] : []),
|
|
1290
|
+
]);
|
|
1291
|
+
/** Whether `path` is a file once this run's writes and deletes, less the held ones, land. */
|
|
1292
|
+
const presentAfter = (path) => {
|
|
1293
|
+
if (heldPaths.has(path))
|
|
1294
|
+
return isFileOnDisk(path);
|
|
1295
|
+
if (adding.has(path) || rewrittenLater.has(path))
|
|
1296
|
+
return true;
|
|
1297
|
+
if (deleting.has(path))
|
|
1298
|
+
return false;
|
|
1299
|
+
return isFileOnDisk(path);
|
|
1300
|
+
};
|
|
1301
|
+
const sourcesOf = new Map();
|
|
1302
|
+
for (const path of moving) {
|
|
1303
|
+
const writing = writtenBytes.get(path);
|
|
1304
|
+
const written = writing !== undefined ? writing.toString("utf8") : undefined;
|
|
1305
|
+
const newBytes = newMap.get(path);
|
|
1306
|
+
const newText = newBytes !== undefined ? Buffer.from(newBytes).toString("utf8") : undefined;
|
|
1307
|
+
const all = [];
|
|
1308
|
+
if (TEST_FILE.test(path)) {
|
|
1309
|
+
const abs = join(targetDir, ...path.split("/"));
|
|
1310
|
+
if (existsSync(abs))
|
|
1311
|
+
all.push(readFileSync(abs, "utf8"));
|
|
1312
|
+
if (newText !== undefined)
|
|
1313
|
+
all.push(newText);
|
|
1314
|
+
if (written !== undefined)
|
|
1315
|
+
all.push(written);
|
|
1316
|
+
}
|
|
1317
|
+
else {
|
|
1318
|
+
const text = written ?? newText;
|
|
1319
|
+
if (text !== undefined)
|
|
1320
|
+
all.push(text);
|
|
1321
|
+
}
|
|
1322
|
+
sourcesOf.set(path, { all, written });
|
|
1323
|
+
}
|
|
1324
|
+
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
|
1325
|
+
const hold = (path, file, missing) => {
|
|
1326
|
+
const entry = heldBackByFile.get(file) ?? { tests: [], importers: [], missing };
|
|
1327
|
+
const list = TEST_FILE.test(path) ? entry.tests : entry.importers;
|
|
1328
|
+
if (!list.includes(path))
|
|
1329
|
+
list.push(path);
|
|
1330
|
+
heldBackByFile.set(file, entry);
|
|
1331
|
+
};
|
|
1332
|
+
const importsCache = new Map();
|
|
1333
|
+
const namedImportsAt = (path, text) => {
|
|
1334
|
+
let hit = importsCache.get(path);
|
|
1335
|
+
if (hit === undefined)
|
|
1336
|
+
importsCache.set(path, (hit = namedImportsOf(text)));
|
|
1337
|
+
return hit;
|
|
1338
|
+
};
|
|
1339
|
+
const textCache = new Map();
|
|
1340
|
+
const diskText = (path) => {
|
|
1341
|
+
if (!textCache.has(path)) {
|
|
1342
|
+
textCache.set(path, isFileOnDisk(path) ? readFileSync(join(targetDir, ...path.split("/")), "utf8") : undefined);
|
|
1343
|
+
}
|
|
1344
|
+
return textCache.get(path);
|
|
1345
|
+
};
|
|
1346
|
+
// Every app source file this run does not write: the code that keeps the
|
|
1347
|
+
// site's version and must still find what it imports.
|
|
1348
|
+
const stayingFiles = [...listFilesUnder(targetDir, ["apps/web", "apps/api", "apps/mcp"])]
|
|
1349
|
+
.filter((path) => !writtenBytes.has(path) && !deleting.has(path))
|
|
1159
1350
|
.sort();
|
|
1160
|
-
|
|
1351
|
+
let grew = true;
|
|
1352
|
+
const holdPath = (path) => {
|
|
1353
|
+
heldPaths.add(path);
|
|
1354
|
+
heldFiles.add(path);
|
|
1355
|
+
grew = true;
|
|
1356
|
+
};
|
|
1357
|
+
while (grew) {
|
|
1161
1358
|
grew = false;
|
|
1162
1359
|
for (const path of moving) {
|
|
1163
|
-
if (
|
|
1360
|
+
if (heldPaths.has(path))
|
|
1164
1361
|
continue;
|
|
1165
|
-
const
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1362
|
+
const { all, written } = sourcesOf.get(path);
|
|
1363
|
+
const imported = new Set(all.flatMap((text) => importedHeldFiles(path, text, heldFiles)));
|
|
1364
|
+
const missing = new Set();
|
|
1365
|
+
if (written !== undefined && !TEST_FILE.test(path)) {
|
|
1366
|
+
for (const specifier of localSpecifiers(path, written)) {
|
|
1367
|
+
const candidates = relativeImportCandidates(path, specifier);
|
|
1368
|
+
if (candidates.some((c) => heldFiles.has(c) || presentAfter(c)))
|
|
1369
|
+
continue;
|
|
1370
|
+
// A dot-directory (`.source/`, `.next/`) is build output the site
|
|
1371
|
+
// generates, never a file update could have carried.
|
|
1372
|
+
if (/(^|\/)\.[^./][^/]*\//.test(candidates[0]))
|
|
1373
|
+
continue;
|
|
1374
|
+
missing.add(candidates[0]);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
// A named import the file it resolves to — staying at the site's
|
|
1378
|
+
// version — does not export (a regenerated nav-config, say).
|
|
1379
|
+
const lacking = new Set();
|
|
1380
|
+
if (written !== undefined) {
|
|
1381
|
+
for (const { specifier, names } of namedImportsOf(written)) {
|
|
1382
|
+
if (!isLocal(path, specifier))
|
|
1383
|
+
continue;
|
|
1384
|
+
const target = relativeImportCandidates(path, specifier).find(isFileOnDisk);
|
|
1385
|
+
if (target === undefined || heldFiles.has(target) || rewrittenLater.has(target))
|
|
1386
|
+
continue;
|
|
1387
|
+
if (writtenBytes.has(target) && !heldPaths.has(target))
|
|
1388
|
+
continue;
|
|
1389
|
+
if (deleting.has(target))
|
|
1390
|
+
continue; // the missing check above owns it
|
|
1391
|
+
if (!SOURCE_FILE.test(target))
|
|
1392
|
+
continue;
|
|
1393
|
+
const text = diskText(target);
|
|
1394
|
+
if (text !== undefined && names.some((name) => !exportsName(text, name)))
|
|
1395
|
+
lacking.add(target);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
if (imported.size === 0 && missing.size === 0 && lacking.size === 0)
|
|
1399
|
+
continue;
|
|
1400
|
+
holdPath(path);
|
|
1401
|
+
for (const file of imported)
|
|
1402
|
+
hold(path, file, false);
|
|
1403
|
+
for (const file of missing)
|
|
1404
|
+
hold(path, file, true);
|
|
1405
|
+
for (const file of lacking)
|
|
1406
|
+
hold(path, file, false);
|
|
1407
|
+
}
|
|
1408
|
+
// The reverse: a file staying at the site's version imports, by name,
|
|
1409
|
+
// something the written copy of its target no longer exports.
|
|
1410
|
+
for (const staying of [...stayingFiles, ...heldPaths]) {
|
|
1411
|
+
const text = diskText(staying);
|
|
1412
|
+
if (text === undefined)
|
|
1413
|
+
continue;
|
|
1414
|
+
// A file left alone keeps the code it was written against: every
|
|
1415
|
+
// template file it imports stays with it, whatever that file exports.
|
|
1416
|
+
if (leftAlone.has(staying)) {
|
|
1417
|
+
for (const specifier of localSpecifiers(staying, text)) {
|
|
1418
|
+
const target = relativeImportCandidates(staying, specifier).find((c) => writtenBytes.has(c) && !heldPaths.has(c));
|
|
1419
|
+
if (target === undefined || !SOURCE_FILE.test(target))
|
|
1420
|
+
continue;
|
|
1421
|
+
holdPath(target);
|
|
1422
|
+
hold(target, staying, false);
|
|
1423
|
+
}
|
|
1174
1424
|
continue;
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1425
|
+
}
|
|
1426
|
+
for (const { specifier, names } of namedImportsAt(staying, text)) {
|
|
1427
|
+
if (!isLocal(staying, specifier))
|
|
1428
|
+
continue;
|
|
1429
|
+
const target = relativeImportCandidates(staying, specifier).find((c) => writtenBytes.has(c) && !heldPaths.has(c));
|
|
1430
|
+
if (target === undefined || !SOURCE_FILE.test(target))
|
|
1431
|
+
continue;
|
|
1432
|
+
const next = writtenBytes.get(target).toString("utf8");
|
|
1433
|
+
if (names.every((name) => exportsName(next, name)))
|
|
1434
|
+
continue;
|
|
1435
|
+
holdPath(target);
|
|
1436
|
+
hold(target, staying, false);
|
|
1180
1437
|
}
|
|
1181
1438
|
}
|
|
1182
1439
|
}
|
|
1183
|
-
for (const path of
|
|
1440
|
+
for (const path of heldPaths) {
|
|
1184
1441
|
const recorded = record.files[path];
|
|
1185
1442
|
if (recorded !== undefined)
|
|
1186
1443
|
nextFiles[path] = recorded;
|
|
1187
1444
|
else
|
|
1188
1445
|
delete nextFiles[path];
|
|
1189
1446
|
}
|
|
1190
|
-
const notHeld = (path) => !
|
|
1447
|
+
const notHeld = (path) => !heldPaths.has(path);
|
|
1191
1448
|
const heldBack = [...heldBackByFile.entries()]
|
|
1192
1449
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
1193
|
-
.map(([file, tests]) => ({
|
|
1450
|
+
.map(([file, { tests, importers, missing }]) => ({
|
|
1451
|
+
file,
|
|
1452
|
+
tests: tests.sort(),
|
|
1453
|
+
...(importers.length > 0 ? { importers: importers.sort() } : {}),
|
|
1454
|
+
...(missing ? { missing: true } : {}),
|
|
1455
|
+
}));
|
|
1194
1456
|
// ── Apply: writes and removals, then the record (D4 Order) ───────────
|
|
1195
1457
|
// #9566 — the API move's removals first: a retired file at a boot-set
|
|
1196
1458
|
// path is overwritten by the write that follows, never deleted after it.
|
|
@@ -1216,7 +1478,9 @@ export async function updateSite(opts) {
|
|
|
1216
1478
|
removed: removed.filter(notHeld),
|
|
1217
1479
|
current: current.filter(notHeld),
|
|
1218
1480
|
};
|
|
1219
|
-
const recordChanged = backfilled !== undefined ||
|
|
1481
|
+
const recordChanged = backfilled !== undefined ||
|
|
1482
|
+
droppedRows.length > 0 ||
|
|
1483
|
+
Object.values(moved).some((paths) => paths.length > 0);
|
|
1220
1484
|
if (recordChanged) {
|
|
1221
1485
|
writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files: nextFiles }));
|
|
1222
1486
|
}
|
|
@@ -1262,6 +1526,35 @@ export async function updateSite(opts) {
|
|
|
1262
1526
|
writeFileSync(join(targetDir, ...WEB_PACKAGE_JSON.split("/")), webPackageJson);
|
|
1263
1527
|
moved.updated.push(WEB_PACKAGE_JSON);
|
|
1264
1528
|
}
|
|
1529
|
+
// #10172 — the env vars the written files read get an env_contract row
|
|
1530
|
+
// when the site's project.yaml has a block and no row for them, so the
|
|
1531
|
+
// code update brings never fails `env:declared`. Rows are appended, never
|
|
1532
|
+
// edited; a hub with no block is not checked and gains none.
|
|
1533
|
+
const envRowsAdded = [];
|
|
1534
|
+
const projectYamlPath = join(targetDir, "project.yaml");
|
|
1535
|
+
const writtenPaths = new Set(writes.map((w) => w.path).filter(notHeld));
|
|
1536
|
+
if (writtenPaths.size > 0 && existsSync(projectYamlPath)) {
|
|
1537
|
+
const yamlText = readFileSync(projectYamlPath, "utf8");
|
|
1538
|
+
const topology = readManifestTopology(targetDir);
|
|
1539
|
+
if (readEnvContract(yamlText).present && "topology" in topology) {
|
|
1540
|
+
try {
|
|
1541
|
+
const plan = planEnvRows(targetDir, yamlText, {
|
|
1542
|
+
topology: topology.topology,
|
|
1543
|
+
catalog: ENV_CATALOG,
|
|
1544
|
+
onlyFiles: writtenPaths,
|
|
1545
|
+
});
|
|
1546
|
+
if (plan.added.length > 0) {
|
|
1547
|
+
writeFileSync(projectYamlPath, plan.text);
|
|
1548
|
+
envRowsAdded.push(...plan.added);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
catch (err) {
|
|
1552
|
+
if (!(err instanceof EnvContractError))
|
|
1553
|
+
throw err;
|
|
1554
|
+
review.push({ path: "project.yaml", reason: `no env_contract rows added: ${err.message}` });
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1265
1558
|
// #7495 D6 — the page body owns its H1: give every heading-less mdx-body
|
|
1266
1559
|
// page its title and description, read the rule after the writes so a
|
|
1267
1560
|
// route this run added counts. Its rows join `review` after `heldFiles`
|
|
@@ -1286,6 +1579,8 @@ export async function updateSite(opts) {
|
|
|
1286
1579
|
heldBack,
|
|
1287
1580
|
headingsAdded: headings.written.map(sitePath),
|
|
1288
1581
|
...(backfilled !== undefined ? { backfilled } : {}),
|
|
1582
|
+
...(envRowsAdded.length > 0 ? { envRowsAdded } : {}),
|
|
1583
|
+
...(droppedRows.length > 0 ? { droppedRows: droppedRows.sort() } : {}),
|
|
1289
1584
|
};
|
|
1290
1585
|
}
|
|
1291
1586
|
/** The printed report (D9): one section per non-empty list, in order. */
|
|
@@ -1312,9 +1607,18 @@ export function formatUpdateReport(result) {
|
|
|
1312
1607
|
lines.push(`Needs review (${result.review.length}):`);
|
|
1313
1608
|
lines.push(...result.review.map((row) => ` ${row.path} — ${row.reason}`));
|
|
1314
1609
|
}
|
|
1315
|
-
for (const { file, tests } of result.heldBack) {
|
|
1316
|
-
|
|
1317
|
-
lines.push(
|
|
1610
|
+
for (const { file, tests, importers = [], missing } of result.heldBack) {
|
|
1611
|
+
const held = [...tests, ...importers].sort();
|
|
1612
|
+
lines.push(`Held back with ${file}${missing ? " (not in this site)" : ""} (${held.length}):`);
|
|
1613
|
+
lines.push(...held.map((path) => ` ${path}`));
|
|
1614
|
+
}
|
|
1615
|
+
if (result.envRowsAdded !== undefined) {
|
|
1616
|
+
lines.push(`project.yaml env_contract rows added (${result.envRowsAdded.length}):`);
|
|
1617
|
+
lines.push(...result.envRowsAdded.map((name) => ` ${name}`));
|
|
1618
|
+
}
|
|
1619
|
+
if (result.droppedRows !== undefined) {
|
|
1620
|
+
lines.push(`Dropped from ${TEMPLATE_RECORD_PATH}, no longer recorded (${result.droppedRows.length}):`);
|
|
1621
|
+
lines.push(...result.droppedRows.map((path) => ` ${path}`));
|
|
1318
1622
|
}
|
|
1319
1623
|
if (result.skipped.length > 0) {
|
|
1320
1624
|
lines.push(`Skipped (${result.skipped.length}):`);
|
|
@@ -1328,7 +1632,9 @@ export function formatUpdateReport(result) {
|
|
|
1328
1632
|
result.current.length === 0 &&
|
|
1329
1633
|
result.headingsAdded.length === 0 &&
|
|
1330
1634
|
result.review.length === 0 &&
|
|
1331
|
-
result.skipped.length === 0
|
|
1635
|
+
result.skipped.length === 0 &&
|
|
1636
|
+
result.envRowsAdded === undefined &&
|
|
1637
|
+
result.droppedRows === undefined;
|
|
1332
1638
|
if (nothingHappened) {
|
|
1333
1639
|
return `[create-ailk] Already up to date (create-ailk ${result.version}).\n`;
|
|
1334
1640
|
}
|
|
@@ -1359,11 +1665,18 @@ untouched apps/web, apps/api or apps/mcp file, or a root tools/jest file the
|
|
|
1359
1665
|
app jest configs require, whose template copy changed is overwritten. An
|
|
1360
1666
|
edited file whose template copy also changed is three-way merged with
|
|
1361
1667
|
\`git\` (git must be on PATH); a conflict leaves the file alone and is
|
|
1362
|
-
listed under "Needs review"
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1668
|
+
listed under "Needs review"; an apps/web/messages catalog instead gains the
|
|
1669
|
+
template's new keys and keeps every value it has, and is reviewed only when a
|
|
1670
|
+
key is an object on one side and a value on the other. Every template file,
|
|
1671
|
+
test or not, that imports a file left under "Needs review" or "Skipped" (by a
|
|
1672
|
+
relative or @/ import, transitively) is held at the site's version with it,
|
|
1673
|
+
its record entry is not advanced, and it is listed under "Held back with
|
|
1674
|
+
<file>" — reconcile the file, then run update again to bring it forward. A
|
|
1675
|
+
written file whose import names a file the site will not have is held the
|
|
1676
|
+
same way, under "Held back with <file> (not in this site)". Every other file
|
|
1677
|
+
in the run still updates. When project.yaml has an env_contract block, each
|
|
1678
|
+
env var the written files read that has no row gains one; no row is edited.
|
|
1679
|
+
A record row at a path create-ailk no longer records is dropped and listed.
|
|
1367
1680
|
A site with no record gets one on its first run, from the version its
|
|
1368
1681
|
.ailk-site-manifest.json already records. A site scaffolded before
|
|
1369
1682
|
@working-theory/mcp-server has its copied apps/mcp moved to the boot set:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ailk",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -25,6 +25,7 @@ jest.mock("@working-theory/page-renderer", () => ({
|
|
|
25
25
|
getMdxSource: jest.fn(),
|
|
26
26
|
}));
|
|
27
27
|
|
|
28
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the route passes no `listSlugs`, so `listDetailPages` would list the real content tree; the category's child list is this test's fixture.
|
|
28
29
|
jest.mock("../../../../lib/route-family", () => ({
|
|
29
30
|
__esModule: true,
|
|
30
31
|
listDetailPages: (...args: unknown[]) => mockListDetailPages(...args),
|
|
@@ -222,6 +222,7 @@ describe("TemplatesPage — the generated card grid (#10051 AC-4)", () => {
|
|
|
222
222
|
|
|
223
223
|
it("emits an ItemList with as many elements as cards", async () => {
|
|
224
224
|
const sections = await renderedSections(["a", "b", "c"]);
|
|
225
|
+
// eslint-disable-next-line no-restricted-syntax -- the builder returns a loose JSON-LD node; this test reads one nested key of it
|
|
225
226
|
const jsonLd = buildTemplatesList({
|
|
226
227
|
type: "TemplatesList",
|
|
227
228
|
slug: "templates",
|
|
@@ -162,6 +162,7 @@ export function describeHub(
|
|
|
162
162
|
loaders(listType, childType, children, [HERO, CLOSING]),
|
|
163
163
|
);
|
|
164
164
|
const sections = await input.getPageSections({});
|
|
165
|
+
// eslint-disable-next-line no-restricted-syntax -- buildJsonLd returns a loose JSON-LD node; this case reads one nested key of it
|
|
165
166
|
const jsonLd = buildJsonLd({
|
|
166
167
|
type: listType,
|
|
167
168
|
slug: route,
|
|
@@ -1025,6 +1025,12 @@ export interface PlanEnvRowsOptions {
|
|
|
1025
1025
|
* with no reader and so no `scope`; a platform or refused name is skipped.
|
|
1026
1026
|
*/
|
|
1027
1027
|
declare?: readonly string[];
|
|
1028
|
+
/**
|
|
1029
|
+
* When set, only reads in these site-relative files get a row (#10173):
|
|
1030
|
+
* `update` declares what the files it wrote read, never a name only the
|
|
1031
|
+
* site's own code reads.
|
|
1032
|
+
*/
|
|
1033
|
+
onlyFiles?: ReadonlySet<string>;
|
|
1028
1034
|
}
|
|
1029
1035
|
|
|
1030
1036
|
/** The header the writer puts above a block it creates. */
|
|
@@ -1066,6 +1072,7 @@ export function planEnvRows(
|
|
|
1066
1072
|
const refused = new Set(opts.topology === "spoke" ? SPOKE_REFUSED_ENV : []);
|
|
1067
1073
|
const files = new Map<string, string[]>();
|
|
1068
1074
|
for (const read of collectSiteReads(root).reads) {
|
|
1075
|
+
if (opts.onlyFiles !== undefined && !opts.onlyFiles.has(read.file)) continue;
|
|
1069
1076
|
if (isPlatformEnvName(read.name) || refused.has(read.name)) continue;
|
|
1070
1077
|
if (declared.has(read.name)) continue;
|
|
1071
1078
|
const list = files.get(read.name) ?? [];
|
package/versions.json
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@working-theory/ui": "0.50.0",
|
|
39
39
|
"@working-theory/validation": "0.50.0",
|
|
40
40
|
"@working-theory/web-i18n": "0.50.0",
|
|
41
|
-
"@working-theory/admin": "0.7.
|
|
41
|
+
"@working-theory/admin": "0.7.1",
|
|
42
42
|
"@working-theory/api-commerce": "0.1.4",
|
|
43
43
|
"@working-theory/api-content-write": "0.1.4",
|
|
44
44
|
"@working-theory/api-hub": "0.3.1",
|